From fa4d6acb052ca08932a581e92468647c84355f17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20S=C5=82uszniak?= Date: Tue, 4 Aug 2026 12:46:03 +0200 Subject: [PATCH 1/6] test(cpp): add host GoogleTest suites for the native sources The cpp/ sources are entirely JSI-facing, so the tests link a real Hermes runtime and a minimal ExecuTorch host build, install the production module into it, and drive it from JavaScript the way the TS layer does. 103 tests across dtype, conversions, tensor, schema, math, speech and cv, running in ~2s with no simulator or device. Adds scripts/build-native-test-deps.sh (pinned Hermes + ExecuTorch host builds, ~2 min, cached in CI) and scripts/run-native-tests.sh, plus a native-tests CI job. clang-tidy now skips cpp/tests, whose headers are not part of that script's prerequisites. --- .github/workflows/ci.yml | 52 +++ packages/react-native-executorch/.gitignore | 7 + .../react-native-executorch/compile_flags.txt | 6 + .../cpp/tests/CMakeLists.txt | 180 ++++++++++ .../cpp/tests/README.md | 92 +++++ .../cpp/tests/core/ConversionsTest.cpp | 173 ++++++++++ .../cpp/tests/core/DTypeTest.cpp | 57 ++++ .../cpp/tests/core/SchemaTest.cpp | 198 +++++++++++ .../cpp/tests/core/TensorTest.cpp | 203 +++++++++++ .../cpp/tests/extensions/CvOpsTest.cpp | 314 ++++++++++++++++++ .../cpp/tests/extensions/MathOpsTest.cpp | 202 +++++++++++ .../cpp/tests/extensions/SpeechOpsTest.cpp | 195 +++++++++++ .../cpp/tests/support/JsiTestEnv.cpp | 129 +++++++ .../cpp/tests/support/JsiTestEnv.h | 94 ++++++ .../scripts/build-native-test-deps.sh | 106 ++++++ .../scripts/clang-tidy.sh | 5 +- .../scripts/run-native-tests.sh | 58 ++++ 17 files changed, 2070 insertions(+), 1 deletion(-) create mode 100644 packages/react-native-executorch/cpp/tests/CMakeLists.txt create mode 100644 packages/react-native-executorch/cpp/tests/README.md create mode 100644 packages/react-native-executorch/cpp/tests/core/ConversionsTest.cpp create mode 100644 packages/react-native-executorch/cpp/tests/core/DTypeTest.cpp create mode 100644 packages/react-native-executorch/cpp/tests/core/SchemaTest.cpp create mode 100644 packages/react-native-executorch/cpp/tests/core/TensorTest.cpp create mode 100644 packages/react-native-executorch/cpp/tests/extensions/CvOpsTest.cpp create mode 100644 packages/react-native-executorch/cpp/tests/extensions/MathOpsTest.cpp create mode 100644 packages/react-native-executorch/cpp/tests/extensions/SpeechOpsTest.cpp create mode 100644 packages/react-native-executorch/cpp/tests/support/JsiTestEnv.cpp create mode 100644 packages/react-native-executorch/cpp/tests/support/JsiTestEnv.h create mode 100755 packages/react-native-executorch/scripts/build-native-test-deps.sh create mode 100755 packages/react-native-executorch/scripts/run-native-tests.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bc595841d3..153659b5a2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,3 +39,55 @@ 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 + 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 release that actually carries the + # rewrite's headers.tar.gz. Update this when the branch starts publishing + # its own versioned artifacts. + RNET_BASE_URL: https://github.com/software-mansion/react-native-executorch/releases/download/v0.0.0-rewrite-libs-test + 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 + + - name: Install build tooling + run: sudo apt-get update && sudo apt-get install -y ninja-build libopencv-dev + working-directory: ${{ github.workspace }} + + - 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. + - name: Cache native test dependencies + id: deps-cache + uses: actions/cache@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: Run C++ unit tests + run: scripts/run-native-tests.sh diff --git a/packages/react-native-executorch/.gitignore b/packages/react-native-executorch/.gitignore index 4498ff073d..b6c6977692 100644 --- a/packages/react-native-executorch/.gitignore +++ b/packages/react-native-executorch/.gitignore @@ -4,3 +4,10 @@ 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/ 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..93933f9a00 --- /dev/null +++ b/packages/react-native-executorch/cpp/tests/CMakeLists.txt @@ -0,0 +1,180 @@ +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") + +# --- 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}) + +if(RNE_TESTS_ENABLE_OPENCV) + find_package(OpenCV REQUIRED COMPONENTS core imgproc) + list(APPEND RNE_SOURCES ${OPENCV_SOURCES}) +endif() + +add_library(rne_under_test STATIC ${RNE_SOURCES}) + +target_include_directories(rne_under_test PUBLIC + "${CPP_DIR}" + "${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 ${OpenCV_LIBS}) +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}") + +# --- 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() + +foreach(suite_path IN LISTS TEST_SUITES) + get_filename_component(suite_name "${suite_path}" NAME_WE) + add_executable(${suite_name} "${suite_path}") + target_link_libraries(${suite_name} PRIVATE rne_test_support gtest_main ${ET_RE2_LIBS} ${ET_ABSEIL_LIBS} ${ET_JSON_LIBS}) + 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..4ea8fdfa71 --- /dev/null +++ b/packages/react-native-executorch/cpp/tests/README.md @@ -0,0 +1,92 @@ +# 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` | +| `extensions/` | `math`, `speech`, and (behind OpenCV) `cv` ops | + +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) +RNET_HEADERS_ONLY=1 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 +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 +(`brew install opencv` / `apt-get install libopencv-dev`). Without OpenCV, run +with `RNE_TESTS_ENABLE_OPENCV=OFF` to skip that suite. + +## Keeping the pins honest + +`scripts/build-native-test-deps.sh` pins both dependencies: + +- `HERMES_VERSION` should match `node_modules/react-native/sdks/.hermesversion`, + so the tests run on the engine the apps run on. +- `EXECUTORCH_VERSION` should match the ExecuTorch release that + `third-party/include` is vendored from. The 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. + +## What is deliberately not covered here + +Anything that needs a real `.pte` program: + +- `cpp/core/model.cpp` (loading, execution, output copy-back) +- `schema::validateSpec`, `methodSpecFromMetadata`, `getUsedBackends` — all take + an ExecuTorch `MethodMeta`, which only exists once a program is loaded + +The JSON-parsing and runtime-constraint halves of `schema` *are* covered, since +they need no program. Testing the rest means shipping fixture `.pte` files and a +runtime that can execute them; that belongs in a device/emulator integration job +rather than this host suite, and is 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/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/run-native-tests.sh b/packages/react-native-executorch/scripts/run-native-tests.sh new file mode 100755 index 0000000000..20ddb6403a --- /dev/null +++ b/packages/react-native-executorch/scripts/run-native-tests.sh @@ -0,0 +1,58 @@ +#!/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 + +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 "$@" From f0318c497a217866bf288ed94c6336e68b757309 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20S=C5=82uszniak?= Date: Tue, 4 Aug 2026 13:05:32 +0200 Subject: [PATCH 2/6] test(cpp): source test headers from the v0.10.0-libs release The v0.0.0-rewrite-libs-test prerelease was a throwaway. v0.10.0-libs carries the same ExecuTorch 1.3.1 C++ headers (byte-identical; it only drops the cpuinfo/pthreadpool and Obj-C headers the host tests never include). --- .github/workflows/ci.yml | 9 +++++---- .../react-native-executorch/cpp/tests/README.md | 15 ++++++++++----- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 153659b5a2..8cfd4dbfed 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,10 +48,11 @@ jobs: 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 release that actually carries the - # rewrite's headers.tar.gz. Update this when the branch starts publishing - # its own versioned artifacts. - RNET_BASE_URL: https://github.com/software-mansion/react-native-executorch/releases/download/v0.0.0-rewrite-libs-test + # 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 diff --git a/packages/react-native-executorch/cpp/tests/README.md b/packages/react-native-executorch/cpp/tests/README.md index 4ea8fdfa71..446408f8bf 100644 --- a/packages/react-native-executorch/cpp/tests/README.md +++ b/packages/react-native-executorch/cpp/tests/README.md @@ -49,8 +49,12 @@ and `ctest -R MathOpsTest` targets a single suite. Two one-time provisioning steps, then the runner: ```bash -# 1. ExecuTorch/OpenCV/tokenizer headers (shared with clang-tidy and clangd) -RNET_HEADERS_ONLY=1 node scripts/download-libs.js +# 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 @@ -71,9 +75,10 @@ with `RNE_TESTS_ENABLE_OPENCV=OFF` to skip that suite. - `HERMES_VERSION` should match `node_modules/react-native/sdks/.hermesversion`, so the tests run on the engine the apps run on. - `EXECUTORCH_VERSION` should match the ExecuTorch release that - `third-party/include` is vendored from. The 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. + `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. ## What is deliberately not covered here From 03a0cea6bc15f81bc20edf1e49c40def0d772f67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20S=C5=82uszniak?= Date: Tue, 4 Aug 2026 13:08:16 +0200 Subject: [PATCH 3/6] ci: point clang-tidy header provisioning at v0.10.0-libs Keeps it on the same headers source as the native-tests job instead of the throwaway v0.0.0-rewrite-libs-test prerelease. The C++ headers are identical, and the full cpp/ set clang-tidy sweeps compiles against them. --- .github/workflows/clang-tidy.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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 From 31cfac6a7685aa4faea947bd3b8fbd15b7c794cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20S=C5=82uszniak?= Date: Tue, 4 Aug 2026 13:49:59 +0200 Subject: [PATCH 4/6] test(cpp): cover the model load path with a real .pte fixture MethodMeta only exists once a program is loaded, which previously left schema::methodSpecFromMetadata/validateSpec/getUsedBackends and all of ModelHostObject's constructor untested. They turn out not to need the XNNPACK delegate: Module::load() and Module::method_meta() both parse the program without initialising backends (only load_method() resolves them). So the whole load path runs on the host against selfie-segmentation, the smallest model the org publishes at ~486 KB, pinned to an exact HF revision and checksum-verified. Adds 18 tests (121 total). The fixture is downloaded rather than committed; when absent the suites are dropped with a CMake warning instead of failing. Execution still needs a host XNNPACK build and stays out of scope. --- .github/workflows/ci.yml | 6 + packages/react-native-executorch/.gitignore | 3 + .../cpp/tests/CMakeLists.txt | 21 ++ .../cpp/tests/README.md | 42 ++-- .../cpp/tests/core/ModelTest.cpp | 205 ++++++++++++++++++ .../scripts/fetch-test-fixtures.sh | 62 ++++++ .../scripts/run-native-tests.sh | 8 + 7 files changed, 334 insertions(+), 13 deletions(-) create mode 100644 packages/react-native-executorch/cpp/tests/core/ModelTest.cpp create mode 100755 packages/react-native-executorch/scripts/fetch-test-fixtures.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8cfd4dbfed..a1d08ac93b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -90,5 +90,11 @@ jobs: if: steps.deps-cache.outputs.cache-hit != 'true' run: scripts/build-native-test-deps.sh + # ~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/packages/react-native-executorch/.gitignore b/packages/react-native-executorch/.gitignore index b6c6977692..2a5b7c9529 100644 --- a/packages/react-native-executorch/.gitignore +++ b/packages/react-native-executorch/.gitignore @@ -11,3 +11,6 @@ dist-artifacts/ # 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/cpp/tests/CMakeLists.txt b/packages/react-native-executorch/cpp/tests/CMakeLists.txt index 93933f9a00..dc9db0f4c1 100644 --- a/packages/react-native-executorch/cpp/tests/CMakeLists.txt +++ b/packages/react-native-executorch/cpp/tests/CMakeLists.txt @@ -154,6 +154,23 @@ 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) @@ -172,6 +189,10 @@ 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}") diff --git a/packages/react-native-executorch/cpp/tests/README.md b/packages/react-native-executorch/cpp/tests/README.md index 446408f8bf..475e3b6518 100644 --- a/packages/react-native-executorch/cpp/tests/README.md +++ b/packages/react-native-executorch/cpp/tests/README.md @@ -38,8 +38,9 @@ rather than a lookalike. | Path | Contents | | --- | --- | | `support/JsiTestEnv.*` | Fixture owning a Hermes runtime with the module installed, plus `eval*` helpers | -| `core/` | `dtype`, `conversions`, `tensor`, `schema` | +| `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. @@ -59,7 +60,7 @@ RNET_HEADERS_ONLY=1 \ # 2. Hermes + a minimal ExecuTorch host build (~2 min, cached afterwards) scripts/build-native-test-deps.sh -# 3. Build and run +# 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 ``` @@ -80,18 +81,33 @@ with `RNE_TESTS_ENABLE_OPENCV=OFF` to skip that suite. 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. -## What is deliberately not covered here +## 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: -Anything that needs a real `.pte` program: +- `schema::methodSpecFromMetadata`, `validateSpec`, `getUsedBackends` +- `loadModel`, and the `path` / `schema` / `backends` JS surface -- `cpp/core/model.cpp` (loading, execution, output copy-back) -- `schema::validateSpec`, `methodSpecFromMetadata`, `getUsedBackends` — all take - an ExecuTorch `MethodMeta`, which only exists once a program is loaded +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 -The JSON-parsing and runtime-constraint halves of `schema` *are* covered, since -they need no program. Testing the rest means shipping fixture `.pte` files and a -runtime that can execute them; that belongs in a device/emulator integration job -rather than this host suite, and is the natural next step. +**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. +**`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/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/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 index 20ddb6403a..cf2459a76b 100755 --- a/packages/react-native-executorch/scripts/run-native-tests.sh +++ b/packages/react-native-executorch/scripts/run-native-tests.sh @@ -46,6 +46,14 @@ if [ ! -f "${REPO_ROOT}/third-party/googletest/CMakeLists.txt" ]; then 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}" From 9ed33da888cc905bbba3c4a11a296947bc7651a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20S=C5=82uszniak?= Date: Tue, 4 Aug 2026 15:09:23 +0200 Subject: [PATCH 5/6] ci: install only the OpenCV modules the cv extension uses `libopencv-dev` is a meta-package that hard-depends on the viz and contrib modules, pulling VTK, OpenMPI, tesseract and ~220 packages. On a throttled Azure mirror that ran past 50 minutes (libvtk9 alone: 20 MB in ~10 min). --no-install-recommends would not have helped, since those are hard Depends. Install libopencv-{core,imgproc}-dev instead. OpenCVConfig.cmake ships only in the meta-package, so cpp/tests now prefers OpenCV's CMake package when present and otherwise locates core/imgproc directly; both paths verified to build and pass all 121 tests. Also caps the job at 30 minutes so a stalled download fails with logs rather than running to the 6 h default. --- .github/workflows/ci.yml | 15 ++++++- .../cpp/tests/CMakeLists.txt | 40 ++++++++++++++++++- .../cpp/tests/README.md | 18 +++++++-- 3 files changed, 67 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a1d08ac93b..1bbfb6a229 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,6 +43,9 @@ jobs: 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 @@ -70,9 +73,19 @@ jobs: 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 ninja-build libopencv-dev + 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 diff --git a/packages/react-native-executorch/cpp/tests/CMakeLists.txt b/packages/react-native-executorch/cpp/tests/CMakeLists.txt index dc9db0f4c1..c67f6633a6 100644 --- a/packages/react-native-executorch/cpp/tests/CMakeLists.txt +++ b/packages/react-native-executorch/cpp/tests/CMakeLists.txt @@ -121,8 +121,44 @@ 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. +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}") + target_include_directories(rne_opencv INTERFACE ${OpenCV_INCLUDE_DIRS}) + 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}") + target_include_directories(rne_opencv INTERFACE "${OPENCV_INCLUDE_DIR}") + target_link_libraries(rne_opencv INTERFACE "${OPENCV_CORE_LIB}" "${OPENCV_IMGPROC_LIB}") +endfunction() + if(RNE_TESTS_ENABLE_OPENCV) - find_package(OpenCV REQUIRED COMPONENTS core imgproc) + rne_find_opencv() list(APPEND RNE_SOURCES ${OPENCV_SOURCES}) endif() @@ -146,7 +182,7 @@ endif() if(RNE_TESTS_ENABLE_OPENCV) target_compile_definitions(rne_under_test PUBLIC RNE_ENABLE_OPENCV) - target_link_libraries(rne_under_test PUBLIC ${OpenCV_LIBS}) + target_link_libraries(rne_under_test PUBLIC rne_opencv) endif() # --- Test support ----------------------------------------------------------- diff --git a/packages/react-native-executorch/cpp/tests/README.md b/packages/react-native-executorch/cpp/tests/README.md index 475e3b6518..72674e15f5 100644 --- a/packages/react-native-executorch/cpp/tests/README.md +++ b/packages/react-native-executorch/cpp/tests/README.md @@ -65,9 +65,21 @@ 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 -(`brew install opencv` / `apt-get install libopencv-dev`). Without OpenCV, run -with `RNE_TESTS_ENABLE_OPENCV=OFF` to skip that suite. +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 From 59581bc3f5bfd16ddc4238b08018d0483fedad31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20S=C5=82uszniak?= Date: Tue, 4 Aug 2026 16:05:39 +0200 Subject: [PATCH 6/6] fix(tests): repair two Linux-only link failures in the C++ test build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both were masked on macOS. re2/abseil were linked before libtokenizers.a, their consumer. GNU ld resolves archives left to right and only pulls members satisfying an already-undefined symbol, so nothing was pulled and the re2 symbols came out undefined; Apple's linker searches regardless of order. They are now interface dependencies of et_tokenizers, so CMake emits them after it, wrapped in --start-group on GNU ld to cover the cycles between abseil archives. OpenCV was compiled against the vendored third-party/include/opencv2 headers (4.13) while linking the system library (4.6 on Ubuntu) — cvtColor resolved to the AlgorithmHint overload added in 4.10 and failed at link. Homebrew's 4.12 was new enough to hide it. Vendored headers are now -isystem (matching compile_flags.txt, and silencing ExecuTorch's deprecation warnings) while the installed OpenCV goes on -I, which is always searched first, so headers and library now come from the same install. Also splits the dependency cache into restore/save so a failing test no longer discards the ~9 min build. --- .github/workflows/ci.yml | 15 ++++- .../cpp/tests/CMakeLists.txt | 62 +++++++++++++++++-- 2 files changed, 70 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1bbfb6a229..6d1eba5015 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -92,9 +92,13 @@ jobs: # Hermes and ExecuTorch are pinned to exact tags, so the cache only misses # when scripts/build-native-test-deps.sh changes those pins. - - name: Cache native test dependencies + # + # 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@v5 + 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') }} @@ -103,6 +107,13 @@ jobs: 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. diff --git a/packages/react-native-executorch/cpp/tests/CMakeLists.txt b/packages/react-native-executorch/cpp/tests/CMakeLists.txt index c67f6633a6..77475e3ac1 100644 --- a/packages/react-native-executorch/cpp/tests/CMakeLists.txt +++ b/packages/react-native-executorch/cpp/tests/CMakeLists.txt @@ -108,6 +108,23 @@ file(GLOB ET_RE2_LIBS "${ET_BUILD_DIR}/extension/llm/tokenizers/third-party/re2/ 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. @@ -127,13 +144,24 @@ set(RNE_SOURCES # 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}") - target_include_directories(rne_opencv INTERFACE ${OpenCV_INCLUDE_DIRS}) + # 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() @@ -153,7 +181,7 @@ function(rne_find_opencv) endif() message(STATUS "OpenCV: using ${OPENCV_CORE_LIB}") - target_include_directories(rne_opencv INTERFACE "${OPENCV_INCLUDE_DIR}") + set(RNE_OPENCV_INCLUDE_DIRS "${OPENCV_INCLUDE_DIR}" PARENT_SCOPE) target_link_libraries(rne_opencv INTERFACE "${OPENCV_CORE_LIB}" "${OPENCV_IMGPROC_LIB}") endfunction() @@ -164,8 +192,14 @@ endif() add_library(rne_under_test STATIC ${RNE_SOURCES}) -target_include_directories(rne_under_test PUBLIC - "${CPP_DIR}" +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" @@ -183,6 +217,22 @@ 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 ----------------------------------------------------------- @@ -232,6 +282,8 @@ endif() foreach(suite_path IN LISTS TEST_SUITES) get_filename_component(suite_name "${suite_path}" NAME_WE) add_executable(${suite_name} "${suite_path}") - target_link_libraries(${suite_name} PRIVATE rne_test_support gtest_main ${ET_RE2_LIBS} ${ET_ABSEIL_LIBS} ${ET_JSON_LIBS}) + # 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()