From 182a91eadec17a696715a74c1b4123282165ffee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20S=C5=82uszniak?= Date: Thu, 28 May 2026 16:22:43 +0200 Subject: [PATCH 1/5] test: add API contract test scaffold for the TS surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Jest-based scaffold and two representative tests that catch drift across the package's public API surface. The tests intentionally do not exercise the JSI runtime — they discover modules/hooks via the index exports and assert shared structural contracts. Refs #1018 --- .../__tests__/api/modelRegistry.test.ts | 86 +++++++++++++++++ .../__tests__/api/moduleContracts.test.ts | 92 +++++++++++++++++++ .../__tests__/mocks/react-native.ts | 21 +++++ .../__tests__/setup-globals.ts | 32 +++++++ .../react-native-executorch/jest.config.js | 25 +++++ packages/react-native-executorch/package.json | 2 + .../tsconfig.test.json | 11 +++ 7 files changed, 269 insertions(+) create mode 100644 packages/react-native-executorch/__tests__/api/modelRegistry.test.ts create mode 100644 packages/react-native-executorch/__tests__/api/moduleContracts.test.ts create mode 100644 packages/react-native-executorch/__tests__/mocks/react-native.ts create mode 100644 packages/react-native-executorch/__tests__/setup-globals.ts create mode 100644 packages/react-native-executorch/jest.config.js create mode 100644 packages/react-native-executorch/tsconfig.test.json diff --git a/packages/react-native-executorch/__tests__/api/modelRegistry.test.ts b/packages/react-native-executorch/__tests__/api/modelRegistry.test.ts new file mode 100644 index 0000000000..ae94d027c0 --- /dev/null +++ b/packages/react-native-executorch/__tests__/api/modelRegistry.test.ts @@ -0,0 +1,86 @@ +import { models } from '../../src/constants/modelRegistry'; + +type Accessor = (...args: unknown[]) => unknown; + +function isAccessor(v: unknown): v is Accessor { + return typeof v === 'function'; +} + +function walk( + node: unknown, + path: string[], + visit: (path: string[], leaf: Accessor) => void +) { + if (isAccessor(node)) { + visit(path, node); + return; + } + if (node && typeof node === 'object') { + for (const [k, v] of Object.entries(node)) { + walk(v, [...path, k], visit); + } + } +} + +type Entry = { name: string; path: string[]; value: unknown }; + +// Accessors that take required arguments and so can't be invoked with no +// args. Inconsistent with the rest of the registry, but kept as-is for now. +// Listed here so the walker can skip them. +const PARAMETERIZED_ACCESSORS = new Set(['ocr.craft']); + +function collect(): Entry[] { + const out: Entry[] = []; + walk(models, [], (path, accessor) => { + const name = path.join('.'); + if (PARAMETERIZED_ACCESSORS.has(name)) return; + out.push({ name, path, value: accessor() }); + }); + return out; +} + +describe('Model registry', () => { + const entries = collect(); + + it('contains accessors', () => { + expect(entries.length).toBeGreaterThan(0); + }); + + it.each(entries.map((e) => [e.name, e.value] as const))( + '%s returns a non-null object', + (_name, value) => { + expect(value).not.toBeNull(); + expect(typeof value).toBe('object'); + } + ); + + // text_to_speech accessors return TextToSpeechModelConfig (no modelName); + // every other branch returns { modelName, modelSource, ... }. + const standard = entries.filter((e) => e.path[0] !== 'text_to_speech'); + + it.each(standard.map((e) => [e.name, e.value] as const))( + '%s exposes a non-empty modelName', + (_name, value) => { + const v = value as { modelName?: unknown }; + expect(typeof v.modelName).toBe('string'); + expect(v.modelName).not.toBe(''); + } + ); + + it('non-TTS modelNames are unique within each category', () => { + const byCategory = new Map(); + for (const { path, value } of standard) { + const cat = path[0]!; + const modelName = (value as { modelName: string }).modelName; + const bucket = byCategory.get(cat) ?? []; + bucket.push(modelName); + byCategory.set(cat, bucket); + } + const collisions: Array<{ category: string; duplicates: string[] }> = []; + for (const [category, names] of byCategory) { + const duplicates = names.filter((n, i) => names.indexOf(n) !== i); + if (duplicates.length > 0) collisions.push({ category, duplicates }); + } + expect(collisions).toEqual([]); + }); +}); diff --git a/packages/react-native-executorch/__tests__/api/moduleContracts.test.ts b/packages/react-native-executorch/__tests__/api/moduleContracts.test.ts new file mode 100644 index 0000000000..2d67599dd1 --- /dev/null +++ b/packages/react-native-executorch/__tests__/api/moduleContracts.test.ts @@ -0,0 +1,92 @@ +import * as RNE from '../../src'; +import { BaseModule } from '../../src/modules/BaseModule'; + +// Module classes that exist purely as shared bases and have no corresponding +// public hook. Anything not in this set is treated as part of the public API +// surface. +const ABSTRACT_MODULES = new Set([ + 'BaseModule', + 'VisionModule', + 'VisionLabeledModule', +]); + +// Modules that don't currently extend BaseModule. This is a known +// inconsistency in today's API — every other module class inherits the +// `delete()` / `forward()` plumbing from BaseModule. Listed here so the +// contract test passes on the current codebase; entries should be removed as +// each module is migrated. +const SKIPS_BASE_MODULE = new Set([ + 'OCRModule', + 'VerticalOCRModule', + 'LLMModule', + 'SpeechToTextModule', + 'TextToSpeechModule', + 'TokenizerModule', +]); + +// Modules that instantiate via something other than a static `from*` factory +// (e.g. ExecutorchModule constructs blank and exposes an instance `load()`; +// TokenizerModule has no factory at all). +const SKIPS_STATIC_FACTORY = new Set(['ExecutorchModule', 'TokenizerModule']); + +// `useExecutorchModule` keeps the `Module` suffix while every other hook +// drops it (`useClassification`, `useLLM`, ...). Listed here so the contract +// test passes today — remove the entry when the hook is renamed. +const HOOK_NAME_EXCEPTIONS: Record = { + ExecutorchModule: 'useExecutorchModule', +}; + +type ModuleCtor = new (...args: never[]) => unknown; + +function isClassConstructor(value: unknown): value is ModuleCtor { + return ( + typeof value === 'function' && + typeof (value as { prototype?: unknown }).prototype === 'object' && + (value as { prototype: { constructor?: unknown } }).prototype + .constructor === value + ); +} + +function getModuleClasses(): Array<[string, ModuleCtor]> { + return Object.entries(RNE).filter( + ([name, value]) => + name.endsWith('Module') && + !name.startsWith('use') && + !ABSTRACT_MODULES.has(name) && + isClassConstructor(value) + ) as Array<[string, ModuleCtor]>; +} + +describe('Module contracts', () => { + const modules = getModuleClasses(); + + it('exports at least one concrete Module class', () => { + expect(modules.length).toBeGreaterThan(0); + }); + + describe.each(modules)('%s', (name, ModuleClass) => { + const baseTest = SKIPS_BASE_MODULE.has(name) ? it.skip : it; + baseTest('extends BaseModule', () => { + expect(ModuleClass.prototype instanceof BaseModule).toBe(true); + }); + + const factoryTest = SKIPS_STATIC_FACTORY.has(name) ? it.skip : it; + factoryTest('declares at least one static factory method (from*)', () => { + const factories = Object.getOwnPropertyNames(ModuleClass).filter( + (n) => + n.startsWith('from') && + typeof (ModuleClass as unknown as Record)[n] === + 'function' + ); + expect(factories.length).toBeGreaterThan(0); + }); + }); + + it.each(modules)('%s has a corresponding hook export', (name) => { + const expected = + HOOK_NAME_EXCEPTIONS[name] ?? 'use' + name.replace(/Module$/, ''); + const hook = (RNE as unknown as Record)[expected]; + expect(hook).toBeDefined(); + expect(typeof hook).toBe('function'); + }); +}); diff --git a/packages/react-native-executorch/__tests__/mocks/react-native.ts b/packages/react-native-executorch/__tests__/mocks/react-native.ts new file mode 100644 index 0000000000..6b2062b488 --- /dev/null +++ b/packages/react-native-executorch/__tests__/mocks/react-native.ts @@ -0,0 +1,21 @@ +// Minimal mock for the bits of `react-native` that the package imports at +// module-load time during these contract tests. Extend as new APIs are +// reached. + +export const Platform = { + OS: 'ios' as 'ios' | 'android' | 'web', + select: (specifics: { + ios?: T; + android?: T; + default?: T; + }): T | undefined => specifics.ios ?? specifics.default, +}; + +export const NativeModules: Record = {}; + +export const TurboModuleRegistry = { + get: () => null, + getEnforcing: () => { + throw new Error('TurboModuleRegistry not available in test env'); + }, +}; diff --git a/packages/react-native-executorch/__tests__/setup-globals.ts b/packages/react-native-executorch/__tests__/setup-globals.ts new file mode 100644 index 0000000000..be7cdd2ddf --- /dev/null +++ b/packages/react-native-executorch/__tests__/setup-globals.ts @@ -0,0 +1,32 @@ +// src/index.ts checks for `global.loadXxx` JSI bindings and, if any are missing, +// calls into the native ETInstaller to install them. In Jest there are no JSI +// bindings, so we stub them out here to keep the import path side-effect-free. + +const stub = (() => Promise.resolve({})) as unknown as () => Promise; +const g = globalThis as unknown as Record; + +const JSI_GLOBALS = [ + 'loadStyleTransfer', + 'loadSemanticSegmentation', + 'loadInstanceSegmentation', + 'loadTextToImage', + 'loadExecutorchModule', + 'loadClassification', + 'loadObjectDetection', + 'loadPoseEstimation', + 'loadTokenizerModule', + 'loadTextEmbeddings', + 'loadImageEmbeddings', + 'loadVAD', + 'loadLLM', + 'loadPrivacyFilter', + 'loadSpeechToText', + 'loadTextToSpeechKokoro', + 'loadOCR', + 'loadVerticalOCR', +]; + +for (const name of JSI_GLOBALS) { + g[name] = stub; +} +g.__rne_isEmulator = false; diff --git a/packages/react-native-executorch/jest.config.js b/packages/react-native-executorch/jest.config.js new file mode 100644 index 0000000000..bdd2b8e3da --- /dev/null +++ b/packages/react-native-executorch/jest.config.js @@ -0,0 +1,25 @@ +module.exports = { + rootDir: __dirname, + testEnvironment: 'node', + testMatch: ['/__tests__/**/*.test.ts?(x)'], + setupFiles: ['/__tests__/setup-globals.ts'], + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json'], + transform: { + '^.+\\.(ts|tsx|js|jsx)$': [ + 'babel-jest', + { + babelrc: false, + configFile: false, + presets: [ + ['@babel/preset-env', { targets: { node: 'current' } }], + '@babel/preset-typescript', + ['@babel/preset-react', { runtime: 'automatic' }], + ], + }, + ], + }, + transformIgnorePatterns: ['/node_modules/(?!(@huggingface)/)'], + moduleNameMapper: { + '^react-native$': '/__tests__/mocks/react-native.ts', + }, +}; diff --git a/packages/react-native-executorch/package.json b/packages/react-native-executorch/package.json index 2aceb63d1f..2cfc4fdf84 100644 --- a/packages/react-native-executorch/package.json +++ b/packages/react-native-executorch/package.json @@ -35,6 +35,8 @@ "scripts": { "example": "yarn workspace react-native-executorch-example", "typecheck": "tsc --noEmit", + "typecheck:tests": "tsc --noEmit -p tsconfig.test.json", + "test": "jest", "lint": "eslint \"**/*.{js,ts,tsx}\"", "clean": "del-cli android/build example/android/build example/android/app/build example/ios/build lib", "prepare": "bob build", diff --git a/packages/react-native-executorch/tsconfig.test.json b/packages/react-native-executorch/tsconfig.test.json new file mode 100644 index 0000000000..0806fd4813 --- /dev/null +++ b/packages/react-native-executorch/tsconfig.test.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": ".", + "composite": false, + "noEmit": true, + "types": ["jest", "react", "node"] + }, + "include": ["src", "__tests__"], + "exclude": ["node_modules", "lib"] +} From a932f2f0f6b631bd111d3ce363e5b57fc2c0428d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20S=C5=82uszniak?= Date: Fri, 29 May 2026 09:36:25 +0200 Subject: [PATCH 2/5] test: add hook return contract + model URL validation tests Two more layers on the API contract scaffold: - hookContracts.test.ts: compile-time assertion that every public useXxx hook returns at least { error, isReady, isGenerating, downloadProgress }. Drift in any hook surfaces as a tsc error naming the offending hook. - modelUrls.test.ts: walks every accessor in the model registry, collects every string field that looks like a URL, and asserts each one is a non-empty https URL pointing at the software-mansion HuggingFace org. Refs #1018, #1202. --- .../__tests__/api/hookContracts.test.ts | 80 ++++++++++++++++++ .../__tests__/api/modelUrls.test.ts | 81 +++++++++++++++++++ 2 files changed, 161 insertions(+) create mode 100644 packages/react-native-executorch/__tests__/api/hookContracts.test.ts create mode 100644 packages/react-native-executorch/__tests__/api/modelUrls.test.ts diff --git a/packages/react-native-executorch/__tests__/api/hookContracts.test.ts b/packages/react-native-executorch/__tests__/api/hookContracts.test.ts new file mode 100644 index 0000000000..1c850b4227 --- /dev/null +++ b/packages/react-native-executorch/__tests__/api/hookContracts.test.ts @@ -0,0 +1,80 @@ +import type { RnExecutorchError } from '../../src'; +import type { + useClassification, + useImageEmbeddings, + useInstanceSegmentation, + useObjectDetection, + useOCR, + usePoseEstimation, + useSemanticSegmentation, + useStyleTransfer, + useTextToImage, + useVerticalOCR, +} from '../../src'; +import type { + useExecutorchModule, + useLLM, + usePrivacyFilter, + useSpeechToText, + useTextEmbeddings, + useTextToSpeech, + useTokenizer, + useVAD, +} from '../../src'; + +// Every public `useXxx` hook is expected to expose at least this state shape. +// The contract is enforced at compile time via `satisfies` below — any hook +// whose return type drifts from this contract will fail `tsc -p +// tsconfig.test.json`, naming the offending hook in the error. +type HookBaseState = { + error: RnExecutorchError | null; + isReady: boolean; + isGenerating: boolean; + downloadProgress: number; +}; + +type HookReturn = T extends (...args: never[]) => infer R ? R : never; + +// Allocate a stub value of each hook's return type and assert the whole map +// satisfies `Record`. If a hook's return type does not +// include the base state, `tsc` errors at the `satisfies` clause and reports +// the failing entry. +const HOOK_RETURN_TYPES = { + // computer vision + useClassification: null as unknown as HookReturn, + useImageEmbeddings: null as unknown as HookReturn, + useInstanceSegmentation: null as unknown as HookReturn< + typeof useInstanceSegmentation + >, + useObjectDetection: null as unknown as HookReturn, + useOCR: null as unknown as HookReturn, + usePoseEstimation: null as unknown as HookReturn, + useSemanticSegmentation: null as unknown as HookReturn< + typeof useSemanticSegmentation + >, + useStyleTransfer: null as unknown as HookReturn, + useTextToImage: null as unknown as HookReturn, + useVerticalOCR: null as unknown as HookReturn, + // general + useExecutorchModule: null as unknown as HookReturn< + typeof useExecutorchModule + >, + // natural language processing + useLLM: null as unknown as HookReturn, + usePrivacyFilter: null as unknown as HookReturn, + useSpeechToText: null as unknown as HookReturn, + useTextEmbeddings: null as unknown as HookReturn, + useTextToSpeech: null as unknown as HookReturn, + useTokenizer: null as unknown as HookReturn, + useVAD: null as unknown as HookReturn, +} satisfies Record; + +describe('Hook return contracts', () => { + it('every public hook return type satisfies HookBaseState (compile-time)', () => { + // The real assertion is the `satisfies` clause above, checked by tsc. + // This runtime test exists so the file appears in the Jest report and + // so the symbol is referenced (preventing dead-code elimination + // surprises and surfacing import-time regressions). + expect(Object.keys(HOOK_RETURN_TYPES).length).toBeGreaterThan(0); + }); +}); diff --git a/packages/react-native-executorch/__tests__/api/modelUrls.test.ts b/packages/react-native-executorch/__tests__/api/modelUrls.test.ts new file mode 100644 index 0000000000..af7e7a1822 --- /dev/null +++ b/packages/react-native-executorch/__tests__/api/modelUrls.test.ts @@ -0,0 +1,81 @@ +import { models } from '../../src/constants/modelRegistry'; +import { URL_PREFIX } from '../../src/constants/versions'; + +type Accessor = (...args: unknown[]) => unknown; + +function isAccessor(v: unknown): v is Accessor { + return typeof v === 'function'; +} + +function walk( + node: unknown, + path: string[], + visit: (path: string[], leaf: Accessor) => void +) { + if (isAccessor(node)) { + visit(path, node); + return; + } + if (node && typeof node === 'object') { + for (const [k, v] of Object.entries(node)) { + walk(v, [...path, k], visit); + } + } +} + +const PARAMETERIZED_ACCESSORS = new Set(['ocr.craft']); + +// Collect every (path, string-valued field) pair from the resolved config of +// every accessor. URL fields are detected by value (starts with "http"), so +// new URL-bearing fields are picked up automatically without per-field opt-in. +type UrlEntry = { path: string; field: string; url: string }; + +function collectUrls(): UrlEntry[] { + const urls: UrlEntry[] = []; + walk(models, [], (path, accessor) => { + const name = path.join('.'); + if (PARAMETERIZED_ACCESSORS.has(name)) return; + const config = accessor(); + collectFromValue(name, config, urls); + }); + return urls; +} + +function collectFromValue(path: string, value: unknown, out: UrlEntry[]) { + if (typeof value === 'string' && /^https?:\/\//.test(value)) { + out.push({ path, field: '', url: value }); + return; + } + if (value && typeof value === 'object') { + for (const [k, v] of Object.entries(value)) { + if (typeof v === 'string' && /^https?:\/\//.test(v)) { + out.push({ path, field: k, url: v }); + } else if (v && typeof v === 'object') { + collectFromValue(`${path}.${k}`, v, out); + } + } + } +} + +describe('Model registry URLs', () => { + const urls = collectUrls(); + + it('contains URL-bearing fields', () => { + expect(urls.length).toBeGreaterThan(0); + }); + + it.each(urls.map((e) => [`${e.path} (${e.field})`, e.url] as const))( + '%s is a non-empty https URL', + (_label, url) => { + expect(url).toMatch(/^https:\/\/\S+$/); + expect(url).not.toBe(''); + } + ); + + it.each(urls.map((e) => [`${e.path} (${e.field})`, e.url] as const))( + '%s points at the software-mansion HuggingFace org', + (_label, url) => { + expect(url.startsWith(URL_PREFIX)).toBe(true); + } + ); +}); From 4d492437ea8304373215b65c1a20c18487aea954 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20S=C5=82uszniak?= Date: Fri, 29 May 2026 09:41:42 +0200 Subject: [PATCH 3/5] test: add error code, TTS voice, and API surface contract tests Three more API consistency layers: - errorCodes.test.ts: walks RnExecutorchErrorCode and asserts every entry is a unique non-negative integer with a working reverse lookup, and that constructing RnExecutorchError(code) produces a non-empty message. - ttsVoices.test.ts: walks every Kokoro voice constant and asserts the voice variable-name region (e.g. KOKORO_FRENCH_*) matches the phonemizerConfig.lang, that the voiceSource URL points at the voices/ directory, and that every phonemizer URL lives under the matching /phonemizer// tree. Catches copy-paste bugs across voice configs. - apiSurface.test.ts: snapshots the sorted list of public exports from src/index.ts. Accidental adds/removals show up in the diff; intentional changes need --updateSnapshot. Refs #1018, #1202. --- .../api/__snapshots__/apiSurface.test.ts.snap | 305 ++++++++++++++++++ .../__tests__/api/apiSurface.test.ts | 21 ++ .../__tests__/api/errorCodes.test.ts | 45 +++ .../__tests__/api/ttsVoices.test.ts | 90 ++++++ 4 files changed, 461 insertions(+) create mode 100644 packages/react-native-executorch/__tests__/api/__snapshots__/apiSurface.test.ts.snap create mode 100644 packages/react-native-executorch/__tests__/api/apiSurface.test.ts create mode 100644 packages/react-native-executorch/__tests__/api/errorCodes.test.ts create mode 100644 packages/react-native-executorch/__tests__/api/ttsVoices.test.ts diff --git a/packages/react-native-executorch/__tests__/api/__snapshots__/apiSurface.test.ts.snap b/packages/react-native-executorch/__tests__/api/__snapshots__/apiSurface.test.ts.snap new file mode 100644 index 0000000000..e0ef9b6676 --- /dev/null +++ b/packages/react-native-executorch/__tests__/api/__snapshots__/apiSurface.test.ts.snap @@ -0,0 +1,305 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`Public API surface export names match snapshot 1`] = ` +[ + "ALL_MINILM_L6_V2", + "ALL_MPNET_BASE_V2", + "BIELIK_V3_0_1_5B", + "BIELIK_V3_0_1_5B_QUANTIZED", + "BK_SDM_TINY_VPRED_256", + "BK_SDM_TINY_VPRED_512", + "BaseResourceFetcherClass", + "CLIP_VIT_BASE_PATCH32_IMAGE", + "CLIP_VIT_BASE_PATCH32_IMAGE_QUANTIZED", + "CLIP_VIT_BASE_PATCH32_TEXT", + "ClassificationModule", + "CocoKeypoint", + "CocoLabel", + "CocoLabelYolo", + "DEEPLAB_V3_MOBILENET_V3_LARGE", + "DEEPLAB_V3_MOBILENET_V3_LARGE_QUANTIZED", + "DEEPLAB_V3_RESNET101", + "DEEPLAB_V3_RESNET101_QUANTIZED", + "DEEPLAB_V3_RESNET50", + "DEEPLAB_V3_RESNET50_QUANTIZED", + "DEFAULT_CHAT_CONFIG", + "DEFAULT_CONTEXT_BUFFER_TOKENS", + "DEFAULT_MESSAGE_HISTORY", + "DEFAULT_STRUCTURED_OUTPUT_PROMPT", + "DEFAULT_SYSTEM_PROMPT", + "DISTILUSE_BASE_MULTILINGUAL_CASED_V2_8DA4W", + "DISTILUSE_BASE_MULTILINGUAL_CASED_V2_8DA4W_MODEL", + "DISTILUSE_BASE_MULTILINGUAL_CASED_V2_TOKENIZER", + "DeeplabLabel", + "DownloadStatus", + "EFFICIENTNET_V2_S", + "EFFICIENTNET_V2_S_COREML_FP16_MODEL", + "EFFICIENTNET_V2_S_COREML_FP32_MODEL", + "EFFICIENTNET_V2_S_QUANTIZED", + "EFFICIENTNET_V2_S_XNNPACK_FP32_MODEL", + "EFFICIENTNET_V2_S_XNNPACK_INT8_MODEL", + "ExecutorchModule", + "FASTSAM_S", + "FASTSAM_S_COREML_FP16_MODEL", + "FASTSAM_S_XNNPACK_FP32_MODEL", + "FASTSAM_X", + "FASTSAM_X_COREML_FP16_MODEL", + "FASTSAM_X_XNNPACK_FP32_MODEL", + "FCN_RESNET101", + "FCN_RESNET101_QUANTIZED", + "FCN_RESNET50", + "FCN_RESNET50_QUANTIZED", + "FSMN_VAD", + "FastSAMLabel", + "HAMMER2_1_0_5B", + "HAMMER2_1_0_5B_QUANTIZED", + "HAMMER2_1_1_5B", + "HAMMER2_1_1_5B_QUANTIZED", + "HAMMER2_1_3B", + "HAMMER2_1_3B_QUANTIZED", + "HTTP_CODE", + "IMAGENET1K_MEAN", + "IMAGENET1K_STD", + "ImageEmbeddingsModule", + "Imagenet1kLabel", + "InstanceSegmentationModule", + "KOKORO_AMERICAN_ENGLISH_FEMALE_HEART", + "KOKORO_AMERICAN_ENGLISH_FEMALE_RIVER", + "KOKORO_AMERICAN_ENGLISH_FEMALE_SARAH", + "KOKORO_AMERICAN_ENGLISH_MALE_ADAM", + "KOKORO_AMERICAN_ENGLISH_MALE_MICHAEL", + "KOKORO_AMERICAN_ENGLISH_MALE_SANTA", + "KOKORO_BRITISH_ENGLISH_FEMALE_EMMA", + "KOKORO_BRITISH_ENGLISH_MALE_DANIEL", + "KOKORO_FRENCH_FEMALE_SIWIS", + "KOKORO_GERMAN", + "KOKORO_GERMAN_FEMALE_ANNA", + "KOKORO_HINDI_FEMALE_ALPHA", + "KOKORO_HINDI_MALE_OMEGA", + "KOKORO_HINDI_MALE_PSI", + "KOKORO_ITALIAN_FEMALE_SARA", + "KOKORO_ITALIAN_MALE_NICOLA", + "KOKORO_POLISH", + "KOKORO_POLISH_MALE_MATEUSZ", + "KOKORO_PORTUGUESE_FEMALE_DORA", + "KOKORO_PORTUGUESE_MALE_SANTA", + "KOKORO_SPANISH_FEMALE_DORA", + "KOKORO_SPANISH_MALE_ALEX", + "KOKORO_STANDARD", + "LFM2_5_1_2B_INSTRUCT", + "LFM2_5_1_2B_INSTRUCT_QUANTIZED", + "LFM2_5_350M", + "LFM2_5_350M_QUANTIZED", + "LFM2_5_VL_1_6B_QUANTIZED", + "LFM2_5_VL_450M_QUANTIZED", + "LFM2_VL_1_6B_QUANTIZED", + "LFM2_VL_450M_QUANTIZED", + "LLAMA3_2_1B", + "LLAMA3_2_1B_QLORA", + "LLAMA3_2_1B_SPINQUANT", + "LLAMA3_2_3B", + "LLAMA3_2_3B_QLORA", + "LLAMA3_2_3B_SPINQUANT", + "LLMModule", + "LRASPP_MOBILENET_V3_LARGE", + "LRASPP_MOBILENET_V3_LARGE_QUANTIZED", + "Logger", + "MULTI_QA_MINILM_L6_COS_V1", + "MULTI_QA_MPNET_BASE_DOT_V1", + "MessageCountContextStrategy", + "NoopContextStrategy", + "OCRModule", + "OCR_ABAZA", + "OCR_ADYGHE", + "OCR_AFRIKAANS", + "OCR_ALBANIAN", + "OCR_AVAR", + "OCR_AZERBAIJANI", + "OCR_BELARUSIAN", + "OCR_BOSNIAN", + "OCR_BULGARIAN", + "OCR_CHECHEN", + "OCR_CROATIAN", + "OCR_CZECH", + "OCR_DANISH", + "OCR_DARGWA", + "OCR_DUTCH", + "OCR_ENGLISH", + "OCR_ESTONIAN", + "OCR_FRENCH", + "OCR_GERMAN", + "OCR_HUNGARIAN", + "OCR_ICELANDIC", + "OCR_INDONESIAN", + "OCR_INGUSH", + "OCR_IRISH", + "OCR_ITALIAN", + "OCR_JAPANESE", + "OCR_KANNADA", + "OCR_KARBADIAN", + "OCR_KOREAN", + "OCR_KURDISH", + "OCR_LAK", + "OCR_LATIN", + "OCR_LATVIAN", + "OCR_LEZGHIAN", + "OCR_LITHUANIAN", + "OCR_MALAY", + "OCR_MALTESE", + "OCR_MAORI", + "OCR_MONGOLIAN", + "OCR_NORWEGIAN", + "OCR_OCCITAN", + "OCR_PALI", + "OCR_POLISH", + "OCR_PORTUGUESE", + "OCR_ROMANIAN", + "OCR_RUSSIAN", + "OCR_SERBIAN_CYRILLIC", + "OCR_SERBIAN_LATIN", + "OCR_SIMPLIFIED_CHINESE", + "OCR_SLOVAK", + "OCR_SLOVENIAN", + "OCR_SPANISH", + "OCR_SWAHILI", + "OCR_SWEDISH", + "OCR_TABASSARAN", + "OCR_TAGALOG", + "OCR_TAJIK", + "OCR_TELUGU", + "OCR_TURKISH", + "OCR_UKRAINIAN", + "OCR_UZBEK", + "OCR_VIETNAMESE", + "OCR_WELSH", + "ObjectDetectionModule", + "PARAPHRASE_MULTILINGUAL_MINILM_L12_V2_QUANTIZED", + "PHI_4_MINI_4B", + "PHI_4_MINI_4B_QUANTIZED", + "PRIVACY_FILTER_NEMOTRON", + "PRIVACY_FILTER_OPENAI", + "PoseEstimationModule", + "PrivacyFilterModule", + "QWEN2_5_0_5B", + "QWEN2_5_0_5B_QUANTIZED", + "QWEN2_5_1_5B", + "QWEN2_5_1_5B_QUANTIZED", + "QWEN2_5_3B", + "QWEN2_5_3B_QUANTIZED", + "QWEN3_0_6B", + "QWEN3_0_6B_QUANTIZED", + "QWEN3_1_7B", + "QWEN3_1_7B_QUANTIZED", + "QWEN3_4B", + "QWEN3_4B_QUANTIZED", + "QWEN3_5_0_8B_QUANTIZED", + "QWEN3_5_2B_QUANTIZED", + "RF_DETR_NANO", + "RF_DETR_NANO_COREML_INT8_MODEL", + "RF_DETR_NANO_SEG", + "RF_DETR_NANO_SEG_COREML_INT8_MODEL", + "RF_DETR_NANO_SEG_XNNPACK_FP32_MODEL", + "RF_DETR_NANO_XNNPACK_FP32_MODEL", + "ResourceFetcher", + "ResourceFetcherUtils", + "RnExecutorchError", + "RnExecutorchErrorCode", + "SELFIE_SEGMENTATION", + "SMOLLM2_1_135M", + "SMOLLM2_1_135M_QUANTIZED", + "SMOLLM2_1_1_7B", + "SMOLLM2_1_1_7B_QUANTIZED", + "SMOLLM2_1_360M", + "SMOLLM2_1_360M_QUANTIZED", + "SPECIAL_TOKENS", + "SSDLITE_320_MOBILENET_V3_LARGE", + "SSDLITE_320_MOBILENET_V3_LARGE_COREML_FP16_MODEL", + "SSDLITE_320_MOBILENET_V3_LARGE_XNNPACK_FP32_MODEL", + "STYLE_TRANSFER_CANDY", + "STYLE_TRANSFER_CANDY_QUANTIZED", + "STYLE_TRANSFER_MOSAIC", + "STYLE_TRANSFER_MOSAIC_QUANTIZED", + "STYLE_TRANSFER_RAIN_PRINCESS", + "STYLE_TRANSFER_RAIN_PRINCESS_QUANTIZED", + "STYLE_TRANSFER_UDNIE", + "STYLE_TRANSFER_UDNIE_QUANTIZED", + "ScalarType", + "SelfieSegmentationLabel", + "SemanticSegmentationModule", + "SlidingWindowContextStrategy", + "SourceType", + "SpeechToTextModule", + "StyleTransferModule", + "TextEmbeddingsModule", + "TextToImageModule", + "TextToSpeechModule", + "TokenizerModule", + "VADModule", + "VerticalOCRModule", + "WHISPER_BASE", + "WHISPER_BASE_EN", + "WHISPER_BASE_EN_MODEL_COREML", + "WHISPER_BASE_EN_MODEL_XNNPACK", + "WHISPER_BASE_EN_TOKENIZER", + "WHISPER_BASE_MODEL_COREML", + "WHISPER_BASE_MODEL_XNNPACK", + "WHISPER_BASE_TOKENIZER", + "WHISPER_SMALL", + "WHISPER_SMALL_EN", + "WHISPER_SMALL_EN_MODEL_COREML", + "WHISPER_SMALL_EN_MODEL_XNNPACK", + "WHISPER_SMALL_EN_TOKENIZER", + "WHISPER_SMALL_MODEL_COREML", + "WHISPER_SMALL_MODEL_XNNPACK", + "WHISPER_SMALL_TOKENIZER", + "WHISPER_TINY", + "WHISPER_TINY_EN", + "WHISPER_TINY_EN_MODEL_COREML", + "WHISPER_TINY_EN_MODEL_XNNPACK", + "WHISPER_TINY_EN_TOKENIZER", + "WHISPER_TINY_MODEL_COREML", + "WHISPER_TINY_MODEL_XNNPACK", + "WHISPER_TINY_TOKENIZER", + "YOLO26L", + "YOLO26L_SEG", + "YOLO26M", + "YOLO26M_SEG", + "YOLO26N", + "YOLO26N_POSE", + "YOLO26N_SEG", + "YOLO26S", + "YOLO26S_SEG", + "YOLO26X", + "YOLO26X_SEG", + "cleanupExecutorch", + "fixAndValidateStructuredOutput", + "getModelNameForUrl", + "getStructuredOutputPrompt", + "initExecutorch", + "isAvailable", + "models", + "parseToolCall", + "selectByBox", + "selectByPoint", + "selectByText", + "styleTransferUrls", + "useClassification", + "useExecutorchModule", + "useImageEmbeddings", + "useInstanceSegmentation", + "useLLM", + "useOCR", + "useObjectDetection", + "usePoseEstimation", + "usePrivacyFilter", + "useSemanticSegmentation", + "useSpeechToText", + "useStyleTransfer", + "useTextEmbeddings", + "useTextToImage", + "useTextToSpeech", + "useTokenizer", + "useVAD", + "useVerticalOCR", +] +`; diff --git a/packages/react-native-executorch/__tests__/api/apiSurface.test.ts b/packages/react-native-executorch/__tests__/api/apiSurface.test.ts new file mode 100644 index 0000000000..13ea6c135a --- /dev/null +++ b/packages/react-native-executorch/__tests__/api/apiSurface.test.ts @@ -0,0 +1,21 @@ +import * as RNE from '../../src'; + +// Snapshots the sorted list of public export names from +// `src/index.ts`. Any addition or removal flips the snapshot so the change is +// surfaced in the diff — a deliberate API tweak just needs `--updateSnapshot`, +// an accidental break does not slip through. +describe('Public API surface', () => { + it('export names match snapshot', () => { + const exportNames = Object.keys(RNE).sort(); + expect(exportNames).toMatchSnapshot(); + }); + + it('every export is non-undefined', () => { + for (const [name, value] of Object.entries(RNE)) { + expect({ name, defined: value !== undefined }).toEqual({ + name, + defined: true, + }); + } + }); +}); diff --git a/packages/react-native-executorch/__tests__/api/errorCodes.test.ts b/packages/react-native-executorch/__tests__/api/errorCodes.test.ts new file mode 100644 index 0000000000..e9ffc35bef --- /dev/null +++ b/packages/react-native-executorch/__tests__/api/errorCodes.test.ts @@ -0,0 +1,45 @@ +import { RnExecutorchErrorCode } from '../../src/errors/ErrorCodes'; +import { RnExecutorchError } from '../../src/errors/errorUtils'; + +// TypeScript enums emit a numeric reverse-mapping: `Enum[42] === 'KeyName'`. +// We use that to walk the enum at runtime as `[name, code]` pairs. +function enumEntries(): Array<[string, number]> { + return Object.entries(RnExecutorchErrorCode) + .filter(([, v]) => typeof v === 'number') + .map(([k, v]) => [k, v as number]); +} + +describe('RnExecutorchErrorCode', () => { + const entries = enumEntries(); + + it('contains entries', () => { + expect(entries.length).toBeGreaterThan(0); + }); + + it('every numeric code is unique', () => { + const codes = entries.map(([, v]) => v); + const dupes = codes.filter((c, i) => codes.indexOf(c) !== i); + expect(dupes).toEqual([]); + }); + + it.each(entries)('%s = %s is a non-negative integer', (_name, code) => { + expect(Number.isInteger(code)).toBe(true); + expect(code).toBeGreaterThanOrEqual(0); + }); + + it.each(entries)('%s = %s has a working reverse lookup', (name, code) => { + expect( + (RnExecutorchErrorCode as unknown as Record)[code] + ).toBe(name); + }); + + it.each(entries)( + 'new RnExecutorchError(%s = %s) produces a non-empty message', + (_name, code) => { + const err = new RnExecutorchError(code); + expect(typeof err.message).toBe('string'); + expect(err.message.length).toBeGreaterThan(0); + expect(err.code).toBe(code); + } + ); +}); diff --git a/packages/react-native-executorch/__tests__/api/ttsVoices.test.ts b/packages/react-native-executorch/__tests__/api/ttsVoices.test.ts new file mode 100644 index 0000000000..126e6e9a5a --- /dev/null +++ b/packages/react-native-executorch/__tests__/api/ttsVoices.test.ts @@ -0,0 +1,90 @@ +import * as Voices from '../../src/constants/tts/voices'; +import { URL_PREFIX } from '../../src/constants/versions'; + +// Voice variable-name region prefix → expected `phonemizerConfig.lang`. A +// voice constant exported under e.g. `KOKORO_FRENCH_*` is expected to carry +// `lang: 'fr'`. A mismatch is almost always a copy-paste bug, so we keep the +// map narrow and explicit. +const REGION_TO_LANG: Record = { + AMERICAN_ENGLISH: 'en-us', + BRITISH_ENGLISH: 'en-gb', + FRENCH: 'fr', + SPANISH: 'es', + ITALIAN: 'it', + PORTUGUESE: 'pt', + HINDI: 'hi', + POLISH: 'pl', + GERMAN: 'de', +}; + +type VoiceConfig = { + voiceSource: string; + phonemizerConfig: { + lang: string; + taggerSource?: string; + lexiconSource?: string; + neuralModelSource?: string; + }; + model: { modelName?: string }; +}; + +function regionOf(name: string): string | null { + for (const region of Object.keys(REGION_TO_LANG)) { + if (name.startsWith(`KOKORO_${region}_`)) return region; + } + return null; +} + +function getVoiceEntries(): Array<[string, VoiceConfig]> { + return Object.entries(Voices) + .filter(([name]) => name.startsWith('KOKORO_')) + .map(([name, value]) => [name, value as VoiceConfig]); +} + +describe('Kokoro voices', () => { + const voices = getVoiceEntries(); + + it('exports voices', () => { + expect(voices.length).toBeGreaterThan(0); + }); + + it.each(voices)('%s has a known region prefix', (name) => { + expect(regionOf(name)).not.toBeNull(); + }); + + it.each(voices)( + '%s phonemizerConfig.lang matches its region prefix', + (name, voice) => { + const region = regionOf(name); + if (!region) throw new Error(`No region for ${name}`); + expect(voice.phonemizerConfig.lang).toBe(REGION_TO_LANG[region]); + } + ); + + it.each(voices)( + '%s voiceSource points at the Kokoro voices directory', + (_name, voice) => { + expect(voice.voiceSource.startsWith(URL_PREFIX)).toBe(true); + expect(voice.voiceSource).toMatch(/\/voices\/[^/]+\.bin$/); + } + ); + + it.each(voices)( + '%s phonemizer URLs all live under the voice language directory', + (_name, voice) => { + const { lang, taggerSource, lexiconSource, neuralModelSource } = + voice.phonemizerConfig; + const expectedSegment = `/phonemizer/${lang}/`; + for (const url of [taggerSource, lexiconSource, neuralModelSource]) { + if (url === undefined) continue; + expect(url.startsWith(URL_PREFIX)).toBe(true); + expect(url).toContain(expectedSegment); + } + } + ); + + it.each(voices)('%s references a model with a modelName', (_name, voice) => { + expect(typeof voice.model.modelName).toBe('string'); + expect(voice.model.modelName?.length ?? 0).toBeGreaterThan(0); + }); +}); From 1f6a86c51a298a3c03ad7920066a98de07ac9426 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20S=C5=82uszniak?= Date: Fri, 29 May 2026 10:00:26 +0200 Subject: [PATCH 4/5] test: add hook props, registry compatibility, and prototype surface tests Three more API consistency layers: - hookPropsContract.test.ts: compile-time check that every *Props type exposes preventLoad?: boolean, and that every public useXxx hook takes a single object argument. Surfaces useTextToSpeech as the lone two-arg outlier. - registryHookCompatibility.test.ts: compile-time assertion that every category sample from the model registry is assignable to the matching hook's model prop type. Catches drift between the registry's static return shape and the hook prop shapes. - modulePrototype.test.ts: walks each concrete module's prototype chain (using property descriptors so accessor getters aren't invoked) and asserts at least one public method is reachable and delete() is callable. Also snapshots BaseModule's intrinsic surface so silent additions/renames there fail loudly. Surfaces TokenizerModule's missing delete() as a documented opt-out in SKIPS_DELETE (tracked in #1202). Refs #1018, #1202. --- .../__tests__/api/hookPropsContract.test.ts | 132 ++++++++++++++++++ .../__tests__/api/modulePrototype.test.ts | 87 ++++++++++++ .../api/registryHookCompatibility.test.ts | 66 +++++++++ 3 files changed, 285 insertions(+) create mode 100644 packages/react-native-executorch/__tests__/api/hookPropsContract.test.ts create mode 100644 packages/react-native-executorch/__tests__/api/modulePrototype.test.ts create mode 100644 packages/react-native-executorch/__tests__/api/registryHookCompatibility.test.ts diff --git a/packages/react-native-executorch/__tests__/api/hookPropsContract.test.ts b/packages/react-native-executorch/__tests__/api/hookPropsContract.test.ts new file mode 100644 index 0000000000..271ccadeb4 --- /dev/null +++ b/packages/react-native-executorch/__tests__/api/hookPropsContract.test.ts @@ -0,0 +1,132 @@ +import type { + ClassificationProps, + ExecutorchModuleProps, + ImageEmbeddingsProps, + InstanceSegmentationProps, + LLMProps, + ObjectDetectionProps, + OCRProps, + PoseEstimationProps, + PrivacyFilterProps, + SemanticSegmentationProps, + SpeechToTextProps, + StyleTransferProps, + TextEmbeddingsProps, + TextToImageProps, + TokenizerProps, + VADProps, + VerticalOCRProps, +} from '../../src'; +import type { + useClassification, + useExecutorchModule, + useImageEmbeddings, + useInstanceSegmentation, + useLLM, + useObjectDetection, + useOCR, + usePoseEstimation, + usePrivacyFilter, + useSemanticSegmentation, + useSpeechToText, + useStyleTransfer, + useTextEmbeddings, + useTextToImage, + useTokenizer, + useVAD, + useVerticalOCR, +} from '../../src'; +import { useTextToSpeech } from '../../src'; + +// ───────────────────────────────────────────────────────────────────────────── +// preventLoad presence on every *Props type. tsc errors on the `satisfies` +// clause if a Props type drops the field. +// ───────────────────────────────────────────────────────────────────────────── + +type HasPreventLoad = { preventLoad?: boolean }; + +const PROPS_TYPES_WITH_PREVENT_LOAD = { + ClassificationProps: null as unknown as ClassificationProps, + ExecutorchModuleProps: null as unknown as ExecutorchModuleProps, + ImageEmbeddingsProps: null as unknown as ImageEmbeddingsProps, + InstanceSegmentationProps: + null as unknown as InstanceSegmentationProps, + LLMProps: null as unknown as LLMProps, + ObjectDetectionProps: null as unknown as ObjectDetectionProps, + OCRProps: null as unknown as OCRProps, + PoseEstimationProps: null as unknown as PoseEstimationProps, + PrivacyFilterProps: null as unknown as PrivacyFilterProps, + SemanticSegmentationProps: + null as unknown as SemanticSegmentationProps, + SpeechToTextProps: null as unknown as SpeechToTextProps, + StyleTransferProps: null as unknown as StyleTransferProps, + TextEmbeddingsProps: null as unknown as TextEmbeddingsProps, + TextToImageProps: null as unknown as TextToImageProps, + TokenizerProps: null as unknown as TokenizerProps, + VADProps: null as unknown as VADProps, + VerticalOCRProps: null as unknown as VerticalOCRProps, +} satisfies Record; + +// ───────────────────────────────────────────────────────────────────────────── +// Hook call shape consistency. Every public `useXxx` is expected to take a +// single object argument, so the second positional parameter should resolve +// to `undefined` at the type level. `useTextToSpeech` is the lone outlier — +// it takes `(model, { preventLoad })` — and is documented as a known +// inconsistency until the API is normalized. +// ───────────────────────────────────────────────────────────────────────────── + +type SecondParam = F extends (...args: infer A) => unknown ? A[1] : never; + +// `unknown` for the OK case, an error-bearing object literal otherwise. Used +// as the rhs of `as` so any non-OK type yields a tsc error. +type AssertSingleArg = + SecondParam extends undefined + ? unknown + : { + ERROR: 'hook should take a single object argument'; + actualSecondParam: SecondParam; + }; + +const _HOOKS_TAKE_SINGLE_ARG = { + useClassification: undefined as AssertSingleArg, + useExecutorchModule: undefined as AssertSingleArg, + useImageEmbeddings: undefined as AssertSingleArg, + useInstanceSegmentation: undefined as AssertSingleArg< + typeof useInstanceSegmentation + >, + useLLM: undefined as AssertSingleArg, + useObjectDetection: undefined as AssertSingleArg, + useOCR: undefined as AssertSingleArg, + usePoseEstimation: undefined as AssertSingleArg, + usePrivacyFilter: undefined as AssertSingleArg, + useSemanticSegmentation: undefined as AssertSingleArg< + typeof useSemanticSegmentation + >, + useSpeechToText: undefined as AssertSingleArg, + useStyleTransfer: undefined as AssertSingleArg, + useTextEmbeddings: undefined as AssertSingleArg, + useTextToImage: undefined as AssertSingleArg, + // useTextToSpeech: takes (model, { preventLoad? }) — outlier, see #1202. + useTokenizer: undefined as AssertSingleArg, + useVAD: undefined as AssertSingleArg, + useVerticalOCR: undefined as AssertSingleArg, +}; + +// Suppress noUnusedLocals — the type assertion *is* the test. +// eslint-disable-next-line no-void +void _HOOKS_TAKE_SINGLE_ARG; + +describe('Hook props + signature contracts', () => { + it('every *Props type carries preventLoad (compile-time)', () => { + expect(Object.keys(PROPS_TYPES_WITH_PREVENT_LOAD).length).toBeGreaterThan( + 0 + ); + }); + + it('useTextToSpeech is the single known multi-arg hook', () => { + // Documented in #1202. The single-arg assertion above is opted-out for + // this hook so the suite stays green; remove the opt-out once the API is + // normalized. + expect(typeof useTextToSpeech).toBe('function'); + }); +}); diff --git a/packages/react-native-executorch/__tests__/api/modulePrototype.test.ts b/packages/react-native-executorch/__tests__/api/modulePrototype.test.ts new file mode 100644 index 0000000000..14f7c2df04 --- /dev/null +++ b/packages/react-native-executorch/__tests__/api/modulePrototype.test.ts @@ -0,0 +1,87 @@ +import * as RNE from '../../src'; +import { BaseModule } from '../../src/modules/BaseModule'; + +// Mirror the abstract-module set from moduleContracts.test.ts. +const ABSTRACT_MODULES = new Set([ + 'BaseModule', + 'VisionModule', + 'VisionLabeledModule', +]); + +// Modules missing a `delete()` method on their prototype chain — without it, +// the native resources allocated in `load()` / `fromXxx()` can never be +// released, leaking memory across hook unmounts. Tracked in #1202. +const SKIPS_DELETE = new Set(['TokenizerModule']); + +type ModuleClass = new (...args: never[]) => unknown; + +function isClassConstructor(value: unknown): value is ModuleClass { + return ( + typeof value === 'function' && + typeof (value as { prototype?: unknown }).prototype === 'object' && + (value as { prototype: { constructor?: unknown } }).prototype + .constructor === value + ); +} + +function getModuleClasses(): Array<[string, ModuleClass]> { + return Object.entries(RNE).filter( + ([name, value]) => + name.endsWith('Module') && + !name.startsWith('use') && + !ABSTRACT_MODULES.has(name) && + isClassConstructor(value) + ) as Array<[string, ModuleClass]>; +} + +// Walk the prototype chain (excluding Object.prototype) and collect every +// non-constructor, non-private callable surface name. Uses property +// descriptors rather than direct access so accessor properties (getters such +// as VisionModule.runOnFrame) are counted without being invoked — invoking +// them on the prototype with no native module loaded would throw. +function reachablePublicMethods(ModuleClass: ModuleClass): Set { + const out = new Set(); + let proto: object | null = ModuleClass.prototype; + while (proto && proto !== Object.prototype) { + for (const name of Object.getOwnPropertyNames(proto)) { + if (name === 'constructor') continue; + if (name.startsWith('_')) continue; + const desc = Object.getOwnPropertyDescriptor(proto, name); + if (!desc) continue; + if (typeof desc.value === 'function' || typeof desc.get === 'function') { + out.add(name); + } + } + proto = Object.getPrototypeOf(proto); + } + return out; +} + +describe('Module prototype surface', () => { + const modules = getModuleClasses(); + + it.each(modules)( + '%s exposes at least one public instance method on the prototype chain', + (_name, ModuleClass) => { + const methods = reachablePublicMethods(ModuleClass); + expect(methods.size).toBeGreaterThan(0); + } + ); + + describe.each(modules)('%s', (name, ModuleClass) => { + const deleteTest = SKIPS_DELETE.has(name) ? it.skip : it; + deleteTest('has a reachable delete() method', () => { + const methods = reachablePublicMethods(ModuleClass); + expect(methods.has('delete')).toBe(true); + }); + }); + + it('BaseModule itself exposes the documented base surface', () => { + const surface = Object.getOwnPropertyNames(BaseModule.prototype).sort(); + // Stable, intentionally tiny. If BaseModule grows, the diff makes the + // intent explicit; if a method is renamed accidentally, this fails. + expect(surface).toEqual( + ['constructor', 'delete', 'forwardET', 'getInputShape'].sort() + ); + }); +}); diff --git a/packages/react-native-executorch/__tests__/api/registryHookCompatibility.test.ts b/packages/react-native-executorch/__tests__/api/registryHookCompatibility.test.ts new file mode 100644 index 0000000000..2a06bdd11e --- /dev/null +++ b/packages/react-native-executorch/__tests__/api/registryHookCompatibility.test.ts @@ -0,0 +1,66 @@ +import { models } from '../../src/constants/modelRegistry'; +import type { + ClassificationModelSources, + ImageEmbeddingsProps, + InstanceSegmentationModelSources, + LLMProps, + ObjectDetectionModelSources, + OCRProps, + PoseEstimationModelSources, + PrivacyFilterProps, + SemanticSegmentationModelSources, + SpeechToTextProps, + StyleTransferProps, + TextEmbeddingsProps, + TextToImageProps, + TextToSpeechModelConfig, + VADProps, +} from '../../src'; + +// Compile-time assertion: every registry accessor returns a config that is +// assignable to the corresponding hook's `model` prop type. If the registry +// drifts from the hook prop shape, tsc errors here naming the offending +// (accessor → prop) pair. +// +// One sample per category is enough — all accessors in a category go through +// the same `base`/`pair`/`variant` builders so their static return types are +// structurally identical. Add a row only when a new category lands. +// +// Generic hook props (ClassificationProps, etc.) wrap a source-of-truth +// `XxxModelSources` type, and the props' `model` field is `C`. We assert +// against the unwrapped `XxxModelSources` directly so the generic constraint +// can't collapse to `never`. + +function _assertRegistryAssignability() { + // computer vision + models.classification.efficientnet_v2_s() satisfies ClassificationModelSources; + models.object_detection.rf_detr_nano() satisfies ObjectDetectionModelSources; + models.pose_estimation.yolo26n() satisfies PoseEstimationModelSources; + models.semantic_segmentation.deeplab_v3_resnet50() satisfies SemanticSegmentationModelSources; + models.instance_segmentation.yolo26n() satisfies InstanceSegmentationModelSources; + models.style_transfer.candy() satisfies StyleTransferProps['model']; + models.image_embedding.clip_vit_base_patch32_image() satisfies ImageEmbeddingsProps['model']; + models.image_generation.bk_sdm_tiny_vpred_512() satisfies TextToImageProps['model']; + models.ocr.craft({ language: 'en' }) satisfies OCRProps['model']; + + // natural language processing + models.llm.qwen3_4b() satisfies LLMProps['model']; + models.privacy_filter.openai() satisfies PrivacyFilterProps['model']; + models.speech_to_text.whisper_tiny_en() satisfies SpeechToTextProps['model']; + models.text_embedding.all_minilm_l6_v2() satisfies TextEmbeddingsProps['model']; + models.vad.fsmn_vad() satisfies VADProps['model']; + + // TTS leafs return a TextToSpeechModelConfig directly (no `model:` wrapper + // — useTextToSpeech is the outlier that takes the config as a positional + // arg, tracked in #1202). + models.text_to_speech.kokoro.en_us.heart() satisfies TextToSpeechModelConfig; +} +// eslint-disable-next-line no-void +void _assertRegistryAssignability; + +describe('Registry → hook prop compatibility', () => { + it('every category sample is assignable to its hook prop (compile-time)', () => { + // The real assertion is the `satisfies` clause above, checked by tsc. + expect(typeof _assertRegistryAssignability).toBe('function'); + }); +}); From ac187497d13d8675a7bbcd31920f433e505e1e6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20S=C5=82uszniak?= Date: Fri, 29 May 2026 10:16:46 +0200 Subject: [PATCH 5/5] test: add module construction, signature alignment, and CI wiring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - moduleConstruction.test.ts: mocks the ResourceFetcher adapter and constructs every from*-factory-bearing module against a sample config from the registry. Asserts the awaited result is the expected instance and that delete() is callable on the stubbed native module. - moduleHookSignatureAlignment.test.ts: compile-time alignment check between non-generic module prototype methods and the matching hook return field. Catches drift between e.g. LLMModule.prototype.generate and useLLM().generate. Surfaces the TextToImageModule.forward → useTextToImage().generate rename via a dedicated row. - setup-globals.ts: the stubbed loadXxx now resolves to a native module shape with unload() and generateFromFrame() so module delete() and VisionModule's worklet getter work in tests. - .github/workflows/ci.yml: adds a `test` job that runs typecheck:tests and `jest --ci` so the contract suite gates PRs. Refs #1018, #1202. --- .github/workflows/ci.yml | 15 ++ .../__tests__/api/moduleConstruction.test.ts | 169 ++++++++++++++++++ .../api/moduleHookSignatureAlignment.test.ts | 164 +++++++++++++++++ .../__tests__/setup-globals.ts | 10 +- 4 files changed, 357 insertions(+), 1 deletion(-) create mode 100644 packages/react-native-executorch/__tests__/api/moduleConstruction.test.ts create mode 100644 packages/react-native-executorch/__tests__/api/moduleHookSignatureAlignment.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 96fd27ad65..9f0fb0d4b7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,3 +37,18 @@ jobs: - name: Build all packages run: yarn workspaces foreach --all --topological-dev run prepare + + test: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup + uses: ./.github/actions/setup + + - name: Typecheck test files + run: yarn workspace react-native-executorch typecheck:tests + + - name: Run API contract tests + run: yarn workspace react-native-executorch test --ci diff --git a/packages/react-native-executorch/__tests__/api/moduleConstruction.test.ts b/packages/react-native-executorch/__tests__/api/moduleConstruction.test.ts new file mode 100644 index 0000000000..676e53dc10 --- /dev/null +++ b/packages/react-native-executorch/__tests__/api/moduleConstruction.test.ts @@ -0,0 +1,169 @@ +import { models } from '../../src/constants/modelRegistry'; +import { + ClassificationModule, + ImageEmbeddingsModule, + InstanceSegmentationModule, + LLMModule, + ObjectDetectionModule, + PoseEstimationModule, + PrivacyFilterModule, + ResourceFetcher, + SemanticSegmentationModule, + StyleTransferModule, + TextEmbeddingsModule, + TextToImageModule, + VADModule, +} from '../../src'; + +// Stub adapter: every fetch resolves to a fixed fake path, regardless of how +// many sources are passed. Enough for factories that just thread the path +// into `global.loadXxx` (which is itself stubbed to resolve to `{}`). +function mockAdapter() { + return { + fetch: async ( + _onProgress: (p: number) => void, + ...sources: unknown[] + ): Promise<{ paths: string[]; wasDownloaded: boolean[] }> => ({ + paths: sources.map((_, i) => `/tmp/mock-source-${i}.pte`), + wasDownloaded: sources.map(() => true), + }), + readAsString: async () => '{}', + }; +} + +beforeAll(() => { + ResourceFetcher.setAdapter(mockAdapter()); +}); + +afterAll(() => { + ResourceFetcher.resetAdapter(); +}); + +// Each entry constructs a module via its primary factory using a sample +// config from the registry. The asserted contract is the same for all of +// them: the awaited result is a real instance of the module class and +// `delete()` is callable on it. +// Use `Function` for `ModuleClass` so classes with private constructors +// (Classification, ObjectDetection, …) are accepted. `instanceof` only needs +// a function with a `prototype`. +type Construction = { + name: string; + build: () => Promise<{ delete: () => void }>; + + ModuleClass: Function; +}; + +const constructions: Construction[] = [ + { + name: 'ClassificationModule.fromModelName', + ModuleClass: ClassificationModule, + build: () => + ClassificationModule.fromModelName( + models.classification.efficientnet_v2_s() + ) as Promise<{ delete: () => void }>, + }, + { + name: 'ObjectDetectionModule.fromModelName', + ModuleClass: ObjectDetectionModule, + build: () => + ObjectDetectionModule.fromModelName( + models.object_detection.rf_detr_nano() + ) as Promise<{ delete: () => void }>, + }, + { + name: 'PoseEstimationModule.fromModelName', + ModuleClass: PoseEstimationModule, + build: () => + PoseEstimationModule.fromModelName( + models.pose_estimation.yolo26n() + ) as Promise<{ delete: () => void }>, + }, + { + name: 'SemanticSegmentationModule.fromModelName', + ModuleClass: SemanticSegmentationModule, + build: () => + SemanticSegmentationModule.fromModelName( + models.semantic_segmentation.deeplab_v3_resnet50() + ) as Promise<{ delete: () => void }>, + }, + { + name: 'InstanceSegmentationModule.fromModelName', + ModuleClass: InstanceSegmentationModule, + build: () => + InstanceSegmentationModule.fromModelName( + models.instance_segmentation.yolo26n() + ) as Promise<{ delete: () => void }>, + }, + { + name: 'StyleTransferModule.fromModelName', + ModuleClass: StyleTransferModule, + build: () => + StyleTransferModule.fromModelName( + models.style_transfer.candy() + ) as Promise<{ delete: () => void }>, + }, + { + name: 'ImageEmbeddingsModule.fromModelName', + ModuleClass: ImageEmbeddingsModule, + build: () => + ImageEmbeddingsModule.fromModelName( + models.image_embedding.clip_vit_base_patch32_image() + ) as Promise<{ delete: () => void }>, + }, + { + name: 'TextToImageModule.fromModelName', + ModuleClass: TextToImageModule, + build: () => + TextToImageModule.fromModelName( + models.image_generation.bk_sdm_tiny_vpred_512() + ) as Promise<{ delete: () => void }>, + }, + { + name: 'LLMModule.fromModelName', + ModuleClass: LLMModule, + build: () => + LLMModule.fromModelName(models.llm.qwen3_4b()) as Promise<{ + delete: () => void; + }>, + }, + { + name: 'TextEmbeddingsModule.fromModelName', + ModuleClass: TextEmbeddingsModule, + build: () => + TextEmbeddingsModule.fromModelName( + models.text_embedding.all_minilm_l6_v2() + ) as Promise<{ delete: () => void }>, + }, + { + name: 'PrivacyFilterModule.fromModelName', + ModuleClass: PrivacyFilterModule, + build: () => + PrivacyFilterModule.fromModelName( + models.privacy_filter.openai() + ) as Promise<{ delete: () => void }>, + }, + { + name: 'VADModule.fromModelName', + ModuleClass: VADModule, + build: () => + VADModule.fromModelName(models.vad.fsmn_vad()) as Promise<{ + delete: () => void; + }>, + }, +]; + +describe('Module construction (mocked native)', () => { + it.each(constructions)( + '$name yields an instance with a callable delete()', + async ({ build, ModuleClass }) => { + const instance = await build(); + expect(instance).toBeInstanceOf(ModuleClass); + expect(typeof instance.delete).toBe('function'); + // Calling delete on the stubbed instance shouldn't throw — the stub + // nativeModule is `{}` and BaseModule.delete is guarded against null + // nativeModule but not against missing `unload`. Modules that rely on + // `nativeModule.unload()` will throw here, which is itself signal. + expect(() => instance.delete()).not.toThrow(); + } + ); +}); diff --git a/packages/react-native-executorch/__tests__/api/moduleHookSignatureAlignment.test.ts b/packages/react-native-executorch/__tests__/api/moduleHookSignatureAlignment.test.ts new file mode 100644 index 0000000000..96c13cb7d3 --- /dev/null +++ b/packages/react-native-executorch/__tests__/api/moduleHookSignatureAlignment.test.ts @@ -0,0 +1,164 @@ +import type { + ExecutorchModuleType, + ImageEmbeddingsType, + LLMType, + StyleTransferType, + TextEmbeddingsType, + TextToImageType, + TokenizerType, + VADType, +} from '../../src'; +import type { ExecutorchModule } from '../../src'; +import type { ImageEmbeddingsModule } from '../../src'; +import type { LLMModule } from '../../src'; +import type { StyleTransferModule } from '../../src'; +import type { TextEmbeddingsModule } from '../../src'; +import type { TextToImageModule } from '../../src'; +import type { TokenizerModule } from '../../src'; +import type { VADModule } from '../../src'; + +// Compile-time alignment between every non-generic module's primary +// inference method(s) and the matching hook return type's method(s). +// +// The hook wrappers around each module are thin (`(...args) => +// runForward((inst) => inst.method(...args))`), so a Hook → Module signature +// mismatch means the hook silently advertises a narrower or wider surface +// than the module actually supports. The assertions below run via tsc and +// flag any drift naming the (module, method) pair. +// +// Modules with class-level generics (Classification, ObjectDetection, +// PoseEstimation, Semantic/InstanceSegmentation, VerticalOCR) are left out +// of this file because their hook return shape and module prototype shape +// depend on per-call type parameters that don't survive `Parameters<>` / +// `ReturnType<>` extraction. Their alignment is exercised at runtime in +// moduleConstruction.test.ts. + +type EqualParam = Parameters< + F extends (...a: never[]) => unknown ? F : never +>[0] extends Parameters unknown ? G : never>[0] + ? Parameters< + G extends (...a: never[]) => unknown ? G : never + >[0] extends Parameters unknown ? F : never>[0] + ? true + : { + ERROR: 'module accepts inputs the hook does not advertise'; + moduleParam: Parameters< + F extends (...a: never[]) => unknown ? F : never + >[0]; + hookParam: Parameters< + G extends (...a: never[]) => unknown ? G : never + >[0]; + } + : { + ERROR: 'hook accepts inputs the module does not'; + moduleParam: Parameters< + F extends (...a: never[]) => unknown ? F : never + >[0]; + hookParam: Parameters< + G extends (...a: never[]) => unknown ? G : never + >[0]; + }; + +type EqualReturn = + Awaited< + ReturnType unknown ? F : never> + > extends Awaited< + ReturnType unknown ? G : never> + > + ? Awaited< + ReturnType unknown ? G : never> + > extends Awaited< + ReturnType unknown ? F : never> + > + ? true + : { + ERROR: 'module returns more than the hook advertises'; + } + : { ERROR: 'hook returns more than the module produces' }; + +// For each (module, method, hook field) row, both an input-shape and a +// return-shape equality is asserted. Any breakage shows up as the +// satisfies-clause failing with one of the labelled error types above. +const _ALIGNMENT = { + // ExecutorchModule has no `forward` wrapper on its hook return — the hook + // returns the instance's `forward` (Tensor I/O) directly. + executorchModule_forward: { + inputs: true as EqualParam< + ExecutorchModule['forward'], + ExecutorchModuleType['forward'] + >, + returns: true as EqualReturn< + ExecutorchModule['forward'], + ExecutorchModuleType['forward'] + >, + }, + imageEmbeddings_forward: { + inputs: true as EqualParam< + ImageEmbeddingsModule['forward'], + ImageEmbeddingsType['forward'] + >, + returns: true as EqualReturn< + ImageEmbeddingsModule['forward'], + ImageEmbeddingsType['forward'] + >, + }, + llm_generate: { + inputs: true as EqualParam, + returns: true as EqualReturn, + }, + styleTransfer_forward: { + inputs: true as EqualParam< + StyleTransferModule['forward'], + StyleTransferType['forward'] + >, + returns: true as EqualReturn< + StyleTransferModule['forward'], + StyleTransferType['forward'] + >, + }, + textEmbeddings_forward: { + inputs: true as EqualParam< + TextEmbeddingsModule['forward'], + TextEmbeddingsType['forward'] + >, + returns: true as EqualReturn< + TextEmbeddingsModule['forward'], + TextEmbeddingsType['forward'] + >, + }, + // TextToImageModule.forward is renamed to .generate on the hook return. + // Tracked in #1202; alignment still asserted across the renamed pair so the + // signatures don't silently drift. + textToImage_forward_to_generate: { + inputs: true as EqualParam< + TextToImageModule['forward'], + TextToImageType['generate'] + >, + returns: true as EqualReturn< + TextToImageModule['forward'], + TextToImageType['generate'] + >, + }, + tokenizer_encode: { + inputs: true as EqualParam< + TokenizerModule['encode'], + TokenizerType['encode'] + >, + returns: true as EqualReturn< + TokenizerModule['encode'], + TokenizerType['encode'] + >, + }, + vad_forward: { + inputs: true as EqualParam, + returns: true as EqualReturn, + }, +}; +// eslint-disable-next-line no-void +void _ALIGNMENT; + +describe('Module ↔ hook signature alignment', () => { + it('every checked module method aligns with its hook return field (compile-time)', () => { + expect(typeof _ALIGNMENT).toBe('object'); + }); +}); diff --git a/packages/react-native-executorch/__tests__/setup-globals.ts b/packages/react-native-executorch/__tests__/setup-globals.ts index be7cdd2ddf..d16117b2f0 100644 --- a/packages/react-native-executorch/__tests__/setup-globals.ts +++ b/packages/react-native-executorch/__tests__/setup-globals.ts @@ -2,7 +2,15 @@ // calls into the native ETInstaller to install them. In Jest there are no JSI // bindings, so we stub them out here to keep the import path side-effect-free. -const stub = (() => Promise.resolve({})) as unknown as () => Promise; +// Each `loadXxx` resolves to a minimal native-module stub that includes the +// methods modules consistently call: `unload` (for BaseModule.delete) and +// `generateFromFrame` (for VisionModule's worklet getter). Modules that need +// more can replace the stub in their own test. +const stub = (() => + Promise.resolve({ + unload: () => {}, + generateFromFrame: () => {}, + })) as unknown as () => Promise; const g = globalThis as unknown as Record; const JSI_GLOBALS = [