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/__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/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/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/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/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); + } + ); +}); 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/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__/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__/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'); + }); +}); 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); + }); +}); 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..d16117b2f0 --- /dev/null +++ b/packages/react-native-executorch/__tests__/setup-globals.ts @@ -0,0 +1,40 @@ +// 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. + +// 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 = [ + '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"] +}