From 59c822c3c969d19214aa60ee01da3ceaaf3b67e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20S=C5=82uszniak?= Date: Fri, 7 Aug 2026 16:20:53 +0200 Subject: [PATCH 1/7] test(ts): add the Jest harness and a fake native runtime Every path through `src/` bottoms out in `__rnexecutorch_jsi__`, so stubbing those calls per test would only ever assert against the stub. Instead of stubbing, implement the native contract in JavaScript: typed-array-backed tensors with the real byte semantics, JS implementations of the math/cv/speech operators, and a `loadModel` that serves a program the test describes. Task pipelines therefore run end to end. Alongside it, an in-memory blob-util mock with a programmable server (status, body, Range support, and a gate to hold a download open) and a worklets mock that runs worklets inline. Native memory is not garbage collected, so the setup file asserts after every test that nothing allocated through the fake was left undisposed; each pipeline suite gets disposal coverage for free. --- .eslintrc.js | 13 + .../__tests__/README.md | 97 ++ .../__tests__/support/async.ts | 29 + .../__tests__/support/blobUtilMock.ts | 303 +++++ .../__tests__/support/cachePath.ts | 35 + .../__tests__/support/fakeJsi.ts | 287 +++++ .../__tests__/support/fakeOps.ts | 451 +++++++ .../__tests__/support/fakeTensor.ts | 174 +++ .../__tests__/support/fixtures.ts | 96 ++ .../__tests__/support/lifetime.ts | 36 + .../__tests__/support/setup.ts | 63 + .../__tests__/support/workletsMock.ts | 65 + .../react-native-executorch/jest.config.js | 32 + packages/react-native-executorch/package.json | 5 + .../tsconfig.build.json | 2 +- yarn.lock | 1059 ++++++++++++++++- 16 files changed, 2710 insertions(+), 37 deletions(-) create mode 100644 packages/react-native-executorch/__tests__/README.md create mode 100644 packages/react-native-executorch/__tests__/support/async.ts create mode 100644 packages/react-native-executorch/__tests__/support/blobUtilMock.ts create mode 100644 packages/react-native-executorch/__tests__/support/cachePath.ts create mode 100644 packages/react-native-executorch/__tests__/support/fakeJsi.ts create mode 100644 packages/react-native-executorch/__tests__/support/fakeOps.ts create mode 100644 packages/react-native-executorch/__tests__/support/fakeTensor.ts create mode 100644 packages/react-native-executorch/__tests__/support/fixtures.ts create mode 100644 packages/react-native-executorch/__tests__/support/lifetime.ts create mode 100644 packages/react-native-executorch/__tests__/support/setup.ts create mode 100644 packages/react-native-executorch/__tests__/support/workletsMock.ts create mode 100644 packages/react-native-executorch/jest.config.js diff --git a/.eslintrc.js b/.eslintrc.js index c171cca868..4d88af8342 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -79,6 +79,19 @@ module.exports = { 'no-console': 'warn', }, }, + { + // The JSDoc rules exist to keep the generated API reference complete. + // Test helpers are not part of that surface, and requiring a tag per + // parameter on a three-line fixture crowds out the prose that explains + // why the fixture exists. + files: ['packages/react-native-executorch/__tests__/**/*.{ts,tsx}'], + rules: { + 'jsdoc/require-param': 'off', + 'jsdoc/require-param-description': 'off', + 'jsdoc/require-returns': 'off', + 'jsdoc/require-returns-description': 'off', + }, + }, { files: ['**/*.md'], processor: 'markdown/markdown', diff --git a/packages/react-native-executorch/__tests__/README.md b/packages/react-native-executorch/__tests__/README.md new file mode 100644 index 0000000000..df0781f487 --- /dev/null +++ b/packages/react-native-executorch/__tests__/README.md @@ -0,0 +1,97 @@ +# TypeScript API tests + +Jest suites covering the public TypeScript surface under `src/` — the hooks, +the task pipelines, the core primitives, the resource fetcher and the model +registry. They run on a developer machine or a CI runner: no simulator, no +emulator, no device, no `.pte` file, and the whole run finishes in a few +seconds. + +```bash +yarn workspace react-native-executorch test +yarn workspace react-native-executorch test --watch +yarn workspace react-native-executorch test __tests__/tasks # one directory +``` + +Types are checked by the existing `yarn typecheck`, which already covers this +directory. + +## Why a fake native runtime, not stubs + +Every path through `src/` bottoms out in `__rnexecutorch_jsi__`: a pipeline +allocates tensors, hands them to `model.execute`, and pushes them through +`softmax`, `resize` and `nms` on the way in and out. Stubbing those calls per +test would mean each assertion checks the stub rather than the pipeline — the +sorting in `classify`, the suppression in `detectObjects` and the colormap in +`segment` would all go untested. + +So `support/fakeJsi.ts` implements the native contract in JavaScript instead: + +| Piece | What it does | +| --- | --- | +| `support/fakeTensor.ts` | Typed-array-backed tensors with the real `setData`/`getData` byte semantics, `copyTo` windows, and use-after-dispose errors | +| `support/fakeOps.ts` | JS implementations of the `math`, `cv` and `speech` operators | +| `support/fakeJsi.ts` | `createTensor`, `loadModel`, `loadTokenizer`, and the resource trackers | +| `support/blobUtilMock.ts` | In-memory filesystem plus a programmable server (status, body, `Range` support, and a gate to hold a download open) | +| `support/workletsMock.ts` | Runs worklets inline — a worklet is an ordinary function marked for a second runtime | + +A test describes the model it wants and drives the real pipeline over it: + +```ts +fakeJsi.registerModel('/models/classifier.pte', { + schema: exported(method('forward', [f32(1, 3, 4, 4)], [f32(1, 3)])), + execute: writesOutputs([1, 0, 2]), +}); + +const classifier = tracked(await createClassifier(config)); +expect((await classifier.classify(imageBuffer(8, 8))).map((r) => r.label)) + .toEqual(['bird', 'cat', 'dog']); +``` + +The fake is faithful where fidelity changes an assertion — writing a float into +`uint8` storage rounds and clamps the way OpenCV's `saturate_cast` does, and the +tokenizer's methods are closures rather than prototype methods because a real +JSI host object's are — and deliberately simple elsewhere: `resize` is +nearest-neighbor whatever interpolation is asked for, and models the geometry +only. The numerical behavior of the real operators belongs to the C++ suites in +[`cpp/tests/`](../cpp/tests/README.md); what these suites own is the TypeScript +above them. + +## Leak checking + +Native memory is not garbage collected, so anything a test allocates through +the fake and does not dispose is a leak in the code under test. The setup file +asserts that after every test, so each pipeline suite gets disposal coverage +for free. + +Wrap construction in `tracked()` and the harness disposes it at the end of the +test — which also keeps a failing assertion from cascading into a second, +misleading leak error. A test that means to leak calls `allowNativeLeaks()`. + +## Layout + +| Path | Contents | +| --- | --- | +| `core/` | `tensor`, `model`, `runtime`, and the `schema` spec matcher | +| `fetcher/` | `download` (caching, resume, cancellation, shared requests), telemetry, the Android backend | +| `tasks/` | One suite per task pipeline, plus the shared construction-failure behavior | +| `hooks/` | `useModel`, `useResourceDownload`, and the task hooks end to end | +| `extensions/` | The pure-TypeScript helpers: box/point scaling, seeded generators | +| `api/` | Export snapshot, model registry rules, label constants, source-level conventions | +| `support/` | The fake runtime, the mocks, and the fixtures | + +## What is deliberately not covered + +**Numerical behavior of the native operators.** `resize` interpolation, +`cvtColor` conversions and the exact `nms` arithmetic are the C++ suites' job; +duplicating them here would only test the fake. + +**The long stateful worklets.** Whisper's decode loop, the VAD rolling window +and the SDXS diffusion step depend on real model weights, so faking them would +mostly assert against the fixture. What they do get is schema acceptance, +rejection of a mismatched model, and full disposal — including Whisper's nested +tokenizer and VAD pipeline. + +**The thread hop.** Worklets run inline here, so serialization onto a real +worklet runtime is not exercised. The `'worklet'` directive convention that +makes that hop possible *is* checked, by parsing `src/` in +`api/workletDirective.test.ts`. diff --git a/packages/react-native-executorch/__tests__/support/async.ts b/packages/react-native-executorch/__tests__/support/async.ts new file mode 100644 index 0000000000..525be24875 --- /dev/null +++ b/packages/react-native-executorch/__tests__/support/async.ts @@ -0,0 +1,29 @@ +/** + * Microtask helpers for driving promise-based code deterministically. + * + * The fetcher and the pipelines never use timers, so their whole state machine + * advances on microtasks. Spinning microtasks until a condition holds is both + * deterministic and free of the arbitrary sleeps that make async tests flaky. + */ + +/** + * Yields to the microtask queue until `predicate` holds. + * @param predicate The condition to wait for. + * @param what What is being waited for, used in the timeout message. + * @param maxTicks How many microtasks to spin before giving up. + */ +export async function until(predicate: () => boolean, what: string, maxTicks = 100): Promise { + for (let tick = 0; tick < maxTicks; tick++) { + if (predicate()) return; + await Promise.resolve(); + } + throw new Error(`Timed out after ${maxTicks} microtasks waiting for ${what}.`); +} + +/** + * Drains the microtask queue. + * @param ticks How many microtasks to yield. + */ +export async function flush(ticks = 20): Promise { + for (let tick = 0; tick < ticks; tick++) await Promise.resolve(); +} diff --git a/packages/react-native-executorch/__tests__/support/blobUtilMock.ts b/packages/react-native-executorch/__tests__/support/blobUtilMock.ts new file mode 100644 index 0000000000..33592e05ec --- /dev/null +++ b/packages/react-native-executorch/__tests__/support/blobUtilMock.ts @@ -0,0 +1,303 @@ +/** + * Mock of `react-native-blob-util`: an in-memory filesystem plus a programmable + * network layer. + * + * `src/fetcher/fetcher.ts` is almost entirely filesystem choreography — temp + * files, range requests, partial-file assembly, moves — so the interesting + * behavior only shows up when the filesystem actually remembers what was + * written to it. This mock therefore models real file state rather than + * recording calls, and `fakeNet` lets a test decide per URL what the server + * does: status code, body, whether it honors `Range`, and when the response + * completes. + */ + +// ============================================================================ +// In-memory filesystem +// ============================================================================ + +const files = new Map(); +const directories = new Set(); + +// Hand-rolled rather than TextEncoder/TextDecoder: the package's `lib` is +// `ESNext` only, so neither is in scope. Test payloads are ASCII. +const encode = (value: string | Uint8Array): Uint8Array => + typeof value === 'string' ? Uint8Array.from([...value].map((c) => c.charCodeAt(0))) : value; + +const decode = (data: Uint8Array): string => String.fromCharCode(...data); + +export const fakeFs = { + /** Wipes all file and directory state. */ + reset(): void { + files.clear(); + directories.clear(); + }, + /** + * Places a file at `path`. + * @param path The absolute path to write to. + * @param contents The file contents. + */ + write(path: string, contents: string | Uint8Array): void { + files.set(path, encode(contents)); + }, + /** + * Deletes a file, if present. + * @param path The absolute path to delete. + */ + remove(path: string): void { + files.delete(path); + }, + /** + * Reads a file as text. + * @param path The absolute path to read. + * @returns The decoded contents, or `undefined` when the file is absent. + */ + readText(path: string): string | undefined { + const data = files.get(path); + return data === undefined ? undefined : decode(data); + }, + /** + * @param path The absolute path to check. + * @returns Whether a file exists at `path`. + */ + has(path: string): boolean { + return files.has(path); + }, + /** @returns Every path currently holding a file, sorted. */ + paths(): string[] { + return [...files.keys()].sort(); + }, + /** @returns Every directory that was explicitly created, sorted. */ + dirs(): string[] { + return [...directories].sort(); + }, +}; + +// ============================================================================ +// Programmable network +// ============================================================================ + +/** What the fake server does for one URL. */ +export type Route = { + /** HTTP status of the response. Defaults to `200`. */ + status?: number; + /** Response body. Defaults to 16 bytes of `'a'`. */ + body?: string | Uint8Array; + /** Whether a `Range` request is answered with `206` + the tail. Defaults to `true`. */ + supportsRange?: boolean; + /** + * Awaited before the response completes, so a test can hold a download open + * (to observe progress, join a second caller, or abort mid-flight). + */ + gate?: Promise; + /** When set, the request rejects with this error instead of responding. */ + error?: Error; + /** Whether a `HEAD` request reports a content length. Defaults to `true`. */ + headOk?: boolean; +}; + +type Request = { method: string; url: string; headers: Record }; + +const routes = new Map(); +const requests: Request[] = []; + +export const fakeNet = { + /** Wipes all routes and the recorded request log. */ + reset(): void { + routes.clear(); + requests.length = 0; + }, + /** + * Registers what the server does for `url`. + * @param url The exact URL to serve. + * @param route The response script. Defaults to a 16-byte `200`. + */ + serve(url: string, route: Route = {}): void { + routes.set(url, route); + }, + /** @returns Every request the fake server received, in order. */ + requests(): readonly Request[] { + return requests; + }, + /** + * @param method The HTTP method to count. + * @param url The URL to count requests for. + * @returns How many matching requests were made. + */ + countRequests(method: string, url: string): number { + return requests.filter((r) => r.method === method && r.url === url).length; + }, +}; + +const bodyOf = (route: Route): Uint8Array => + encode(route.body ?? 'a'.repeat(16)) as Uint8Array; + +/** A deferred whose `resolve` a test calls to let a gated download finish. */ +export function deferred(): { promise: Promise; resolve: () => void } { + let resolve!: () => void; + const promise = new Promise((r) => { + resolve = r; + }); + return { promise, resolve }; +} + +/** + * Stand-in for the global `fetch`, answering from the same routes as + * `RNBlobUtil`. Only what `src/` uses is implemented: `HEAD` for content + * length, and fire-and-forget `POST`/`HEAD` for telemetry. + * @param url The requested URL. + * @param init The request options. + * @returns A minimal `Response`-shaped object. + */ +export const fakeFetch = async ( + url: string, + init?: { method?: string } +): Promise<{ status: number; headers: { get: (name: string) => string | null } }> => { + const method = init?.method ?? 'GET'; + requests.push({ method, url, headers: {} }); + + const route = routes.get(url); + if (!route) throw new Error(`fakeNet: no route registered for ${url}`); + if (route.error) throw route.error; + + const length = route.headOk === false ? null : String(bodyOf(route).length); + return { + status: route.status ?? 200, + headers: { get: (name: string) => (name.toLowerCase() === 'content-length' ? length : null) }, + }; +}; + +// ============================================================================ +// RNBlobUtil surface +// ============================================================================ + +type ProgressCallback = (received: string, total: string) => void; + +type FetchTask = Promise<{ info: () => { status: number } }> & { + progress: (config: { count?: number }, cb: ProgressCallback) => FetchTask; + cancel: () => void; +}; + +class CancelledError extends Error { + constructor() { + super('Download cancelled.'); + this.name = 'CancelledError'; + } +} + +type Config = { + /** Destination path for a streamed download (iOS-style). */ + path?: string; + fileCache?: boolean; + addAndroidDownloads?: { path?: string; useDownloadManager?: boolean; [key: string]: unknown }; +}; + +function startFetch(config: Config, method: string, url: string, headers: Record) { + const dest = config.addAndroidDownloads?.path ?? config.path; + requests.push({ method, url, headers }); + + let onProgress: ProgressCallback | undefined; + let cancelled = false; + + const run = async () => { + const route = routes.get(url); + if (!route) throw new Error(`fakeNet: no route registered for ${url}`); + if (route.error) throw route.error; + + const full = bodyOf(route); + const rangeHeader = headers.Range ?? headers.range; + const offset = rangeHeader ? Number(/bytes=(\d+)-/.exec(rangeHeader)?.[1] ?? 0) : 0; + + let status = route.status ?? 200; + let payload = full; + if (offset > 0 && status < 400) { + if (offset >= full.length) { + status = 416; + payload = new Uint8Array(0); + } else if (route.supportsRange ?? true) { + status = 206; + payload = full.subarray(offset); + } + // Otherwise the server ignores the range and re-sends everything (200). + } + + // Halfway progress first, so a test can observe a partially finished + // download while the gate is still closed. + onProgress?.(String(Math.floor(payload.length / 2)), String(payload.length)); + if (route.gate) await route.gate; + if (cancelled) throw new CancelledError(); + onProgress?.(String(payload.length), String(payload.length)); + + // A failing status writes no bytes, matching a real streamed download that + // is discarded by the caller. + if (status < 400 && status !== 416 && dest) files.set(dest, payload); + + return { info: () => ({ status }) }; + }; + + const task = run() as FetchTask; + task.progress = (_config, cb) => { + onProgress = cb; + return task; + }; + task.cancel = () => { + cancelled = true; + }; + return task; +} + +const fs = { + dirs: { + DocumentDir: '/fake/documents', + CacheDir: '/fake/caches', + SDCardDir: '/fake/sdcard', + }, + + exists: async (path: string): Promise => files.has(path) || directories.has(path), + + stat: async (path: string): Promise<{ size: number }> => { + const data = files.get(path); + if (!data) throw new Error(`ENOENT: ${path}`); + return { size: data.length }; + }, + + mkdir: async (path: string): Promise => { + if (directories.has(path)) throw new Error(`EEXIST: ${path}`); + directories.add(path); + }, + + unlink: async (path: string): Promise => { + if (!files.delete(path) && !directories.delete(path)) throw new Error(`ENOENT: ${path}`); + }, + + mv: async (from: string, to: string): Promise => { + const data = files.get(from); + if (!data) throw new Error(`ENOENT: ${from}`); + files.delete(from); + files.set(to, data); + }, + + appendFile: async (path: string, source: string, encoding?: string): Promise => { + if (encoding !== 'uri') throw new Error(`blobUtilMock: unsupported encoding '${encoding}'`); + const srcPath = source.replace(/^file:\/\//, ''); + const src = files.get(srcPath); + if (!src) throw new Error(`ENOENT: ${srcPath}`); + const dst = files.get(path) ?? new Uint8Array(0); + const merged = new Uint8Array(dst.length + src.length); + merged.set(dst); + merged.set(src, dst.length); + files.set(path, merged); + return merged.length; + }, +}; + +const RNBlobUtil = { + fs, + config: (config: Config) => ({ + fetch: (method: string, url: string, headers: Record = {}) => + startFetch(config, method, url, headers), + }), + fetch: (method: string, url: string, headers: Record = {}) => + startFetch({}, method, url, headers), +}; + +export default RNBlobUtil; diff --git a/packages/react-native-executorch/__tests__/support/cachePath.ts b/packages/react-native-executorch/__tests__/support/cachePath.ts new file mode 100644 index 0000000000..8d2f83702d --- /dev/null +++ b/packages/react-native-executorch/__tests__/support/cachePath.ts @@ -0,0 +1,35 @@ +/** + * Where a given URL will be cached. + * + * A `use` hook downloads first and loads second, with no gap a test can + * hook into — so a fake program has to be registered under its final path + * before the hook renders. That path is derived inside + * `src/fetcher/fetcher.ts` and not exported, so it is mirrored here. + * + * The duplication is deliberate but guarded: `hooks/taskHooks.test.ts` asserts + * that a real download lands exactly on `cachePathFor(url)`, so a change to the + * derivation fails there rather than silently desynchronizing the fixtures. + */ + +/* eslint-disable no-bitwise */ +const djb2 = (s: string): number => { + let h = 5381; + for (let i = 0; i < s.length; i++) { + h = (((h << 5) + h) ^ s.charCodeAt(i)) >>> 0; + } + return h; +}; +/* eslint-enable no-bitwise */ + +/** The iOS branch of the fetcher's cache directory, as the blob-util mock reports it. */ +const CACHE_DIRECTORY = '/fake/documents/react-native-executorch'; + +/** + * @param url The remote URL a pipeline config points at. + * @returns The local path the fetcher will download it to. + */ +export function cachePathFor(url: string): string { + const withoutQuery = url.split('?')[0]!; + const basename = withoutQuery.split('/').pop() || 'model'; + return `${CACHE_DIRECTORY}/${djb2(withoutQuery)}_${basename}`; +} diff --git a/packages/react-native-executorch/__tests__/support/fakeJsi.ts b/packages/react-native-executorch/__tests__/support/fakeJsi.ts new file mode 100644 index 0000000000..b7fee3cd16 --- /dev/null +++ b/packages/react-native-executorch/__tests__/support/fakeJsi.ts @@ -0,0 +1,287 @@ +/** + * The fake `__rnexecutorch_jsi__` global. + * + * Everything in `src/` bottoms out here, so the depth of this object decides + * what the TypeScript suites can actually exercise. Rather than stubbing each + * call per test — which would only ever assert against the stub — this + * implements the native contract in JavaScript: tensors hold real data, the + * operators compute real values, and `loadModel` returns a program a test has + * described (its schema, and optionally what `execute` writes into the output + * tensors). Task pipelines therefore run end to end. + * + * The C++ side of the same contract is covered by the GoogleTest suites under + * `cpp/tests/`; what these suites own is the TypeScript above it. + */ +import type { ConcreteDim, ModelSpec } from '../../src/core/schema'; +import type { DType } from '../../src/core/tensor'; +import { FakeTensor, tensorTracker } from './fakeTensor'; +import { cv, math, speech } from './fakeOps'; + +// ============================================================================ +// Models +// ============================================================================ + +/** What `execute` does: reads the inputs and fills the pre-allocated outputs. */ +export type FakeExecute = ( + methodName: string, + inputs: readonly unknown[], + outputs: readonly FakeTensor[] +) => void; + +/** A program `loadModel` can return. */ +export type FakeProgram = { + /** The exported schema, as the native loader would derive it. */ + schema: ModelSpec; + /** Per-method backends. Defaults to `XnnpackBackend` for every method. */ + backends?: Record; + /** Output producer. Defaults to leaving the output tensors untouched. */ + execute?: FakeExecute; +}; + +const programs = new Map(); +const liveModels = new Set(); +const executions: { path: string; methodName: string }[] = []; + +class FakeModel { + readonly path: string; + readonly schema: ModelSpec; + readonly backends: Record; + private readonly program: FakeProgram; + private disposed = false; + + constructor(path: string, program: FakeProgram) { + this.path = path; + this.program = program; + this.schema = program.schema; + this.backends = + program.backends ?? + Object.fromEntries(Object.keys(program.schema).map((m) => [m, ['XnnpackBackend']])); + liveModels.add(path); + } + + execute(methodName: string, inputs: unknown[], outputTensors: FakeTensor[]): unknown[] { + if (this.disposed) throw new Error(`execute: model '${this.path}' has been disposed`); + if (!this.schema[methodName]) { + throw new Error(`execute: method '${methodName}' is not exported by '${this.path}'`); + } + executions.push({ path: this.path, methodName }); + this.program.execute?.(methodName, inputs, outputTensors); + return outputTensors; + } + + dispose(): void { + if (this.disposed) return; + this.disposed = true; + liveModels.delete(this.path); + } +} + +// ============================================================================ +// Tokenizers +// ============================================================================ + +/** A tokenizer `loadTokenizer` can return. */ +export type FakeVocabulary = { + /** Token strings, indexed by id. */ + tokens: readonly string[]; + /** Ids prepended to every `encode` result (e.g. a BOS token). */ + prefix?: readonly number[]; + /** Ids appended to every `encode` result (e.g. an EOS token). */ + suffix?: readonly number[]; + /** Ids skipped by `decode` when `skipSpecialTokens` is set. */ + specialIds?: readonly number[]; +}; + +const vocabularies = new Map(); +const liveTokenizers = new Set(); + +/** + * Builds a fake tokenizer. + * + * The methods are closures rather than prototype methods on purpose: a native + * JSI host object's properties are self-contained `jsi::Function`s that capture + * the tokenizer, so they keep working when detached — and the library relies on + * that (`createTokenizer` passes `tokenizer.encode` straight to `wrapAsync`). + * Prototype methods would lose `this` and fail on a contract the real object + * honors. + * @param path The tokenizer path. + * @param vocabulary The vocabulary to serve. + * @returns The fake tokenizer. + */ +function createFakeTokenizer(path: string, vocabulary: FakeVocabulary) { + let disposed = false; + liveTokenizers.add(path); + + const assertLive = (op: string): void => { + if (disposed) throw new Error(`${op}: tokenizer '${path}' has been disposed`); + }; + + return { + path, + + /** + * Whitespace tokenization against the vocabulary; unknown words map to 0. + */ + encode: (text: string): Int32Array => { + assertLive('encode'); + const words = text.split(/\s+/).filter(Boolean); + const ids = words.map((word) => Math.max(0, vocabulary.tokens.indexOf(word))); + return Int32Array.from([...(vocabulary.prefix ?? []), ...ids, ...(vocabulary.suffix ?? [])]); + }, + + decode: (tokens: Int32Array, skipSpecialTokens = true): string => { + assertLive('decode'); + const special = new Set(vocabulary.specialIds ?? []); + return [...tokens] + .filter((id) => !(skipSpecialTokens && special.has(id))) + .map((id) => vocabulary.tokens[id] ?? '') + .filter(Boolean) + .join(' '); + }, + + getVocabSize: (): number => { + assertLive('getVocabSize'); + return vocabulary.tokens.length; + }, + + idToToken: (id: number): string => { + assertLive('idToToken'); + const token = vocabulary.tokens[id]; + if (token === undefined) throw new Error(`idToToken: id ${id} is out of range`); + return token; + }, + + tokenToId: (token: string): number => { + assertLive('tokenToId'); + const id = vocabulary.tokens.indexOf(token); + if (id === -1) throw new Error(`tokenToId: token '${token}' is not in the vocabulary`); + return id; + }, + + dispose: (): void => { + if (disposed) return; + disposed = true; + liveTokenizers.delete(path); + }, + }; +} + +// ============================================================================ +// The global +// ============================================================================ + +let registeredBackends: string[] = ['XnnpackBackend', 'CoreMLBackend']; + +const jsi = { + isEmulator: false, + + createTensor: (shape: number[], dtype: DType) => new FakeTensor(dtype, shape), + + loadModel: (path: string) => { + const program = programs.get(path); + if (!program) { + throw new Error(`loadModel: no program registered at '${path}' (register one via fakeJsi)`); + } + return new FakeModel(path, program); + }, + + getExecuTorchRegisteredBackends: () => [...registeredBackends], + + math, + cv, + speech, + nlp: { + loadTokenizer: (path: string) => { + const vocabulary = vocabularies.get(path); + if (!vocabulary) { + throw new Error(`loadTokenizer: no vocabulary registered at '${path}'`); + } + return createFakeTokenizer(path, vocabulary); + }, + }, +}; + +/** + * Installs the fake under its real global name. Called once from the Jest + * setup file: `src/native/bridge.ts` captures `globalThis.__rnexecutorch_jsi__` + * into a module-level `const` at import time, so the object identity must stay + * stable for the whole run — `reset()` clears state in place rather than + * swapping the global. + */ +export function installFakeJsi(): void { + // eslint-disable-next-line camelcase + (globalThis as Record).__rnexecutorch_jsi__ = jsi; +} + +export const fakeJsi = { + /** + * Makes `loadModel(path)` succeed and return `program`. + * @param path The model path a pipeline will be pointed at. + * @param program The schema, backends and `execute` behavior to serve. + */ + registerModel(path: string, program: FakeProgram): void { + programs.set(path, program); + }, + + /** + * Makes `loadTokenizer(path)` succeed and return a tokenizer over `vocabulary`. + * @param path The tokenizer path a pipeline will be pointed at. + * @param vocabulary The vocabulary to serve. + */ + registerTokenizer(path: string, vocabulary: FakeVocabulary): void { + vocabularies.set(path, vocabulary); + }, + + /** + * Overrides what `getRegisteredBackends()` reports. + * @param backends The backend names to report. + */ + setRegisteredBackends(backends: string[]): void { + registeredBackends = backends; + }, + + /** + * Sets the emulator flag the native installer would provide. + * @param value Whether the fake reports running on an emulator. + */ + setIsEmulator(value: boolean): void { + jsi.isEmulator = value; + }, + + /** @returns Every `execute` call made so far, in order. */ + executions(): readonly { path: string; methodName: string }[] { + return executions; + }, + + /** @returns Paths of models that were loaded and not disposed. */ + liveModels(): string[] { + return [...liveModels].sort(); + }, + + /** @returns Paths of tokenizers that were loaded and not disposed. */ + liveTokenizers(): string[] { + return [...liveTokenizers].sort(); + }, + + /** @returns How many tensors are allocated and not disposed. */ + liveTensors(): number { + return tensorTracker.liveCount(); + }, + + /** @returns A readable description of every tensor still allocated. */ + liveTensorDescriptions(): string[] { + return tensorTracker.liveDescriptions(); + }, + + /** Clears every registration and tracker. Runs automatically between tests. */ + reset(): void { + programs.clear(); + vocabularies.clear(); + liveModels.clear(); + liveTokenizers.clear(); + executions.length = 0; + registeredBackends = ['XnnpackBackend', 'CoreMLBackend']; + jsi.isEmulator = false; + tensorTracker.reset(); + }, +}; diff --git a/packages/react-native-executorch/__tests__/support/fakeOps.ts b/packages/react-native-executorch/__tests__/support/fakeOps.ts new file mode 100644 index 0000000000..3ef89a1c0c --- /dev/null +++ b/packages/react-native-executorch/__tests__/support/fakeOps.ts @@ -0,0 +1,451 @@ +/** + * JavaScript implementations of the native operations exposed under + * `__rnexecutorch_jsi__.{math,cv,speech}`. + * + * These follow the contracts documented on the TypeScript wrappers in + * `src/extensions/`, so a pipeline that composes them produces the values a + * real device would — which is what makes end-to-end pipeline assertions + * meaningful. Two deliberate simplifications: + * + * - `resize` is nearest-neighbor whatever interpolation is requested. Only + * the geometry (stretch / letterbox / crop) is modelled. + * - `cvtColor` uses the standard luminance weights for grayscale and plain channel + * permutation otherwise. + * + * The numerical fidelity of the real operators is the C++ suites' job (see + * `cpp/tests/extensions/`); what is verified here is the pipeline wiring + * around them. + */ +import type { FakeTensor } from './fakeTensor'; + +// ============================================================================ +// Shared helpers +// ============================================================================ + +const expectShape = (t: FakeTensor, expected: readonly number[], what: string): void => { + if (t.shape.length !== expected.length || t.shape.some((d, i) => d !== expected[i])) { + throw new Error(`${what}: expected shape [${expected}], got [${t.shape}]`); + } +}; + +/** + * Resolves a possibly negative axis against a rank, the way the native ops do. + */ +const resolveAxis = (axis: number, rank: number): number => { + const resolved = axis < 0 ? rank + axis : axis; + if (resolved < 0 || resolved >= rank) { + throw new Error(`axis ${axis} is out of range for a rank-${rank} tensor`); + } + return resolved; +}; + +/** + * Splits a shape around `axis` into (outer, axis length, inner) strides, so an + * axis-wise op can be written as a flat triple loop. + */ +const axisLayout = (shape: readonly number[], axis: number) => { + const outer = shape.slice(0, axis).reduce((a, b) => a * b, 1); + const length = shape[axis]!; + const inner = shape.slice(axis + 1).reduce((a, b) => a * b, 1); + return { outer, length, inner }; +}; + +// ============================================================================ +// math +// ============================================================================ + +export const math = { + sigmoid(src: FakeTensor, dst: FakeTensor): FakeTensor { + expectShape(dst, src.shape, 'sigmoid: dst'); + for (let i = 0; i < src.numel; i++) dst.setElement(i, 1 / (1 + Math.exp(-src.getElement(i)))); + return dst; + }, + + softmax(src: FakeTensor, dst: FakeTensor, axis = -1): FakeTensor { + expectShape(dst, src.shape, 'softmax: dst'); + const { outer, length, inner } = axisLayout(src.shape, resolveAxis(axis, src.shape.length)); + + for (let o = 0; o < outer; o++) { + for (let i = 0; i < inner; i++) { + const at = (k: number) => (o * length + k) * inner + i; + + let max = -Infinity; + for (let k = 0; k < length; k++) max = Math.max(max, src.getElement(at(k))); + + let sum = 0; + for (let k = 0; k < length; k++) { + const value = Math.exp(src.getElement(at(k)) - max); + dst.setElement(at(k), value); + sum += value; + } + for (let k = 0; k < length; k++) dst.setElement(at(k), dst.getElement(at(k)) / sum); + } + } + return dst; + }, + + argmax(src: FakeTensor, dst: FakeTensor, axis = -1): FakeTensor { + const resolved = resolveAxis(axis, src.shape.length); + const expected = src.shape.map((d, i) => (i === resolved ? 1 : d)); + expectShape(dst, expected, 'argmax: dst'); + const { outer, length, inner } = axisLayout(src.shape, resolved); + + for (let o = 0; o < outer; o++) { + for (let i = 0; i < inner; i++) { + let best = 0; + let bestValue = -Infinity; + for (let k = 0; k < length; k++) { + const value = src.getElement((o * length + k) * inner + i); + if (value > bestValue) { + bestValue = value; + best = k; + } + } + dst.setElement(o * inner + i, best); + } + } + return dst; + }, + + threshold(src: FakeTensor, dst: FakeTensor, thresholdVal: number): FakeTensor { + expectShape(dst, src.shape, 'threshold: dst'); + for (let i = 0; i < src.numel; i++) { + dst.setElement(i, src.getElement(i) >= thresholdVal ? 1 : 0); + } + return dst; + }, +}; + +// ============================================================================ +// cv +// ============================================================================ + +type ResizeOptions = { mode: string; interpolation: string; padValue: number }; + +/** Per-format channel counts, mirroring `FORMAT_CHANNELS` in `src/`. */ +const CHANNELS: Record = { RGB: 3, BGR: 3, RGBA: 4, BGRA: 4, GRAY: 1 }; + +/** Index of each color in a given format, or -1 when the format has no alpha. */ +const ORDER: Record = { + RGB: ['R', 'G', 'B'], + BGR: ['B', 'G', 'R'], + RGBA: ['R', 'G', 'B'], + BGRA: ['B', 'G', 'R'], + GRAY: ['GRAY'], +}; + +export const cv = { + /** + * Nearest-neighbor resize honouring the three resize modes. `src` and `dst` + * are HWC with matching channel counts. + */ + resize(src: FakeTensor, dst: FakeTensor, opts: ResizeOptions): FakeTensor { + const [srcH, srcW, channels] = src.shape as [number, number, number]; + const [dstH, dstW, dstChannels] = dst.shape as [number, number, number]; + if (channels !== dstChannels) { + throw new Error(`resize: channel mismatch (${channels} vs ${dstChannels})`); + } + + // Region of the source that maps onto the destination, and the region of + // the destination it lands in. + let scale = 1; + let padX = 0; + let padY = 0; + let cropW = srcW; + let cropH = srcH; + let cropX = 0; + let cropY = 0; + + if (opts.mode === 'letterbox') { + scale = Math.min(dstW / srcW, dstH / srcH); + padX = (dstW - srcW * scale) / 2; + padY = (dstH - srcH * scale) / 2; + for (let i = 0; i < dst.numel; i++) dst.setElement(i, opts.padValue); + } else if (opts.mode === 'crop') { + // Centre-crop the source to the destination aspect ratio, then stretch. + const targetRatio = dstW / dstH; + if (srcW / srcH > targetRatio) { + cropW = Math.round(srcH * targetRatio); + cropX = Math.floor((srcW - cropW) / 2); + } else { + cropH = Math.round(srcW / targetRatio); + cropY = Math.floor((srcH - cropH) / 2); + } + } + + const spanW = opts.mode === 'letterbox' ? srcW * scale : dstW; + const spanH = opts.mode === 'letterbox' ? srcH * scale : dstH; + + for (let y = 0; y < Math.round(spanH); y++) { + for (let x = 0; x < Math.round(spanW); x++) { + const sy = cropY + Math.min(cropH - 1, Math.floor((y * cropH) / spanH)); + const sx = cropX + Math.min(cropW - 1, Math.floor((x * cropW) / spanW)); + const dy = Math.round(padY) + y; + const dx = Math.round(padX) + x; + if (dy < 0 || dy >= dstH || dx < 0 || dx >= dstW) continue; + for (let c = 0; c < channels; c++) { + dst.setElement( + (dy * dstW + dx) * channels + c, + src.getElement((sy * srcW + sx) * channels + c) + ); + } + } + } + return dst; + }, + + cvtColor(src: FakeTensor, dst: FakeTensor, code: string): FakeTensor { + const match = /^([A-Z]+)2([A-Z]+)$/.exec(code); + if (!match) throw new Error(`cvtColor: unrecognized code '${code}'`); + const [, from, to] = match as unknown as [string, string, string]; + + const srcChannels = CHANNELS[from]; + const dstChannels = CHANNELS[to]; + if (srcChannels === undefined || dstChannels === undefined) { + throw new Error(`cvtColor: unrecognized code '${code}'`); + } + + const [height, width] = src.shape as [number, number, number]; + expectShape(src, [height, width, srcChannels], `cvtColor(${code}): src`); + expectShape(dst, [height, width, dstChannels], `cvtColor(${code}): dst`); + + const srcOrder = ORDER[from]!; + const dstOrder = ORDER[to]!; + + for (let p = 0; p < height * width; p++) { + const read = (channel: string): number => { + if (from === 'GRAY') return src.getElement(p); + const index = srcOrder.indexOf(channel as never); + return index === -1 ? 0 : src.getElement(p * srcChannels + index); + }; + + if (to === 'GRAY') { + // Rec. 601 luminance, the same weights OpenCV uses. + dst.setElement(p, Math.round(0.299 * read('R') + 0.587 * read('G') + 0.114 * read('B'))); + continue; + } + + if (from === 'GRAY') { + const gray = src.getElement(p); + for (let c = 0; c < 3; c++) dst.setElement(p * dstChannels + c, gray); + } else { + dstOrder.forEach((channel, c) => dst.setElement(p * dstChannels + c, read(channel))); + } + if (dstChannels === 4) dst.setElement(p * dstChannels + 3, 255); + } + return dst; + }, + + toChannelsFirst(src: FakeTensor, dst: FakeTensor): FakeTensor { + const [height, width, channels] = src.shape as [number, number, number]; + expectShape(dst, [channels, height, width], 'toChannelsFirst: dst'); + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + for (let c = 0; c < channels; c++) { + dst.setElement( + c * height * width + y * width + x, + src.getElement((y * width + x) * channels + c) + ); + } + } + } + return dst; + }, + + toChannelsLast(src: FakeTensor, dst: FakeTensor): FakeTensor { + const [channels, height, width] = src.shape as [number, number, number]; + expectShape(dst, [height, width, channels], 'toChannelsLast: dst'); + for (let c = 0; c < channels; c++) { + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + dst.setElement( + (y * width + x) * channels + c, + src.getElement(c * height * width + y * width + x) + ); + } + } + } + return dst; + }, + + normalize( + src: FakeTensor, + dst: FakeTensor, + opts: { alpha?: number | readonly number[]; beta?: number | readonly number[] } + ): FakeTensor { + expectShape(dst, src.shape, 'normalize: dst'); + const channels = src.shape[0]!; + const perChannel = src.numel / channels; + const coefficient = ( + value: number | readonly number[] | undefined, + c: number, + fallback: number + ) => + value === undefined ? fallback : typeof value === 'number' ? value : (value[c] ?? fallback); + + for (let i = 0; i < src.numel; i++) { + const c = Math.floor(i / perChannel); + const alpha = coefficient(opts.alpha, c, 1 / 255); + const beta = coefficient(opts.beta, c, 0); + dst.setElement(i, src.getElement(i) * alpha + beta); + } + return dst; + }, + + applyColormap( + src: FakeTensor, + dst: FakeTensor, + colormap: readonly (readonly [number, number, number, number])[] + ): FakeTensor { + const pixels = src.numel; + expectShape(dst, [...src.shape.slice(0, 2), 4], 'applyColormap: dst'); + for (let p = 0; p < pixels; p++) { + const index = src.getElement(p); + const color = colormap[index]; + if (!color) throw new Error(`applyColormap: class index ${index} has no color`); + for (let c = 0; c < 4; c++) dst.setElement(p * 4 + c, color[c]!); + } + return dst; + }, + + nms( + boxes: FakeTensor, + scores: FakeTensor, + opts: { + boxFormat: string; + iouThreshold: number; + confidenceThreshold: number; + nmsType: 'standard' | 'weighted'; + } + ): number[] | number[][] { + const count = scores.numel; + const corners = (index: number): [number, number, number, number] => { + const a = boxes.getElement(index * 4); + const b = boxes.getElement(index * 4 + 1); + const c = boxes.getElement(index * 4 + 2); + const d = boxes.getElement(index * 4 + 3); + switch (opts.boxFormat) { + case 'xyxy': + return [a, b, c, d]; + case 'xywh': + return [a, b, a + c, b + d]; + case 'cxcywh': + return [a - c / 2, b - d / 2, a + c / 2, b + d / 2]; + default: + throw new Error(`nms: unrecognized box format '${opts.boxFormat}'`); + } + }; + + const iou = (i: number, j: number): number => { + const [ax0, ay0, ax1, ay1] = corners(i); + const [bx0, by0, bx1, by1] = corners(j); + const width = Math.max(0, Math.min(ax1, bx1) - Math.max(ax0, bx0)); + const height = Math.max(0, Math.min(ay1, by1) - Math.max(ay0, by0)); + const overlap = width * height; + const union = (ax1 - ax0) * (ay1 - ay0) + (bx1 - bx0) * (by1 - by0) - overlap; + return union <= 0 ? 0 : overlap / union; + }; + + const candidates = Array.from({ length: count }, (_, i) => i) + .filter((i) => scores.getElement(i) >= opts.confidenceThreshold) + .sort((a, b) => scores.getElement(b) - scores.getElement(a)); + + const kept: number[] = []; + const groups: number[][] = []; + const suppressed = new Set(); + + for (const index of candidates) { + if (suppressed.has(index)) continue; + kept.push(index); + const group = [index]; + for (const other of candidates) { + if (other === index || suppressed.has(other)) continue; + if (iou(index, other) > opts.iouThreshold) { + suppressed.add(other); + group.push(other); + } + } + groups.push(group); + } + + return opts.nmsType === 'weighted' ? groups : kept; + }, + + restrictToBox( + src: FakeTensor, + dst: FakeTensor, + box: readonly [number, number, number, number], + format: string + ): FakeTensor { + expectShape(dst, src.shape, 'restrictToBox: dst'); + const [a, b, c, d] = box; + const [x0, y0, x1, y1] = + format === 'xyxy' + ? [a, b, c, d] + : format === 'xywh' + ? [a, b, a + c, b + d] + : [a - c / 2, b - d / 2, a + c / 2, b + d / 2]; + + const [height, width, channels] = [src.shape[0]!, src.shape[1]!, src.shape[2] ?? 1]; + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const inside = x >= x0 && x < x1 && y >= y0 && y < y1; + for (let ch = 0; ch < channels; ch++) { + const at = (y * width + x) * channels + ch; + dst.setElement(at, inside ? src.getElement(at) : 0); + } + } + } + return dst; + }, +}; + +// ============================================================================ +// speech +// ============================================================================ + +export const speech = { + /** + * Frames a waveform exactly as documented on `extractFrames`: per-frame mean + * removal, pre-emphasis, Hann windowing, into zero-padded rows of `dst`. + */ + extractFrames( + waveform: FakeTensor, + hann: FakeTensor, + dst: FakeTensor, + options: { numFrames: number; hopLength: number; preemphasis: number } + ): FakeTensor { + const frameLength = hann.numel; + const [rows, fftLength] = dst.shape as [number, number]; + if (options.numFrames > rows) { + throw new Error(`extractFrames: numFrames ${options.numFrames} exceeds dst rows ${rows}`); + } + + for (let i = 0; i < dst.numel; i++) dst.setElement(i, 0); + + for (let f = 0; f < options.numFrames; f++) { + const start = f * options.hopLength; + const frame = new Float64Array(frameLength); + for (let i = 0; i < frameLength; i++) { + const at = start + i; + frame[i] = at < waveform.numel ? waveform.getElement(at) : 0; + } + + let mean = 0; + for (const value of frame) mean += value; + mean /= frameLength; + for (let i = 0; i < frameLength; i++) frame[i]! -= mean; + + // Pre-emphasis runs backwards so each sample still sees its raw neighbor. + for (let i = frameLength - 1; i > 0; i--) { + frame[i] = frame[i]! - options.preemphasis * frame[i - 1]!; + } + frame[0] = frame[0]! * (1 - options.preemphasis); + + for (let i = 0; i < Math.min(frameLength, fftLength); i++) { + dst.setElement(f * fftLength + i, frame[i]! * hann.getElement(i)); + } + } + return dst; + }, +}; diff --git a/packages/react-native-executorch/__tests__/support/fakeTensor.ts b/packages/react-native-executorch/__tests__/support/fakeTensor.ts new file mode 100644 index 0000000000..ef0dc9f07c --- /dev/null +++ b/packages/react-native-executorch/__tests__/support/fakeTensor.ts @@ -0,0 +1,174 @@ +/** + * A typed-array-backed stand-in for the native `Tensor` host object. + * + * It implements the contract documented on `src/core/tensor.ts` — including + * the byte-level `setData`/`getData` semantics and the element-wise `copyTo` + * window — and additionally tracks allocation, so a test can assert that a + * pipeline's `dispose()` really releases everything it allocated. + */ +import type { DType } from '../../src/core/tensor'; + +type Storage = Float32Array | Uint8Array | Int32Array | BigInt64Array; + +const STORAGE: Record Storage> = { + float32: Float32Array, + uint8: Uint8Array, + int32: Int32Array, + int64: BigInt64Array, +}; + +const BYTES_PER_ELEMENT: Record = { + float32: 4, + uint8: 1, + int32: 4, + int64: 8, +}; + +/** Live tensors, keyed by their id, so leaks can be reported with their shape. */ +const live = new Map(); +let nextId = 0; +let doubleDisposes = 0; + +export class FakeTensor { + readonly id: number; + readonly dtype: DType; + readonly shape: readonly number[]; + readonly numel: number; + readonly data: Storage; + disposed = false; + + constructor(dtype: DType, shape: readonly number[]) { + if (!(dtype in STORAGE)) throw new Error(`createTensor: unsupported dtype '${dtype}'`); + if (shape.some((d) => !Number.isInteger(d) || d <= 0)) { + throw new Error(`createTensor: invalid shape [${shape}]`); + } + this.id = nextId++; + this.dtype = dtype; + this.shape = Object.freeze([...shape]); + this.numel = shape.reduce((a, b) => a * b, 1); + this.data = new STORAGE[dtype](this.numel); + live.set(this.id, this); + } + + private assertLive(op: string): void { + if (this.disposed) throw new Error(`${op}: tensor ${this.id} has already been disposed`); + } + + /** + * Reads element `index` as a number, widening `int64` out of `bigint`. + */ + getElement(index: number): number { + const value = this.data[index]; + return typeof value === 'bigint' ? Number(value) : (value ?? 0); + } + + /** + * Writes element `index`. + * + * Writing a float into integer storage rounds and clamps, matching OpenCV's + * `saturate_cast` — which is what the native ops use, and what a raw typed + * array assignment would get wrong twice over: JS truncates towards zero, and + * `Uint8Array` wraps modulo 256 instead of clamping. + */ + setElement(index: number, value: number): void { + if (this.data instanceof BigInt64Array) { + this.data[index] = BigInt(Math.round(value)); + } else if (this.data instanceof Uint8Array) { + this.data[index] = Math.min(255, Math.max(0, Math.round(value))); + } else if (this.data instanceof Int32Array) { + this.data[index] = Math.round(value); + } else { + this.data[index] = value; + } + } + + private get byteLength(): number { + return this.numel * BYTES_PER_ELEMENT[this.dtype]; + } + + setData(src: Storage): FakeTensor { + this.assertLive('setData'); + if (src.byteLength !== this.byteLength) { + throw new Error( + `setData: source is ${src.byteLength} bytes, tensor holds ${this.byteLength}` + ); + } + new Uint8Array(this.data.buffer).set( + new Uint8Array(src.buffer as ArrayBuffer, src.byteOffset, src.byteLength) + ); + return this; + } + + getData(dst: T): T { + this.assertLive('getData'); + if (dst.byteLength !== this.byteLength) { + throw new Error( + `getData: destination is ${dst.byteLength} bytes, tensor holds ${this.byteLength}` + ); + } + new Uint8Array(dst.buffer as ArrayBuffer, dst.byteOffset, dst.byteLength).set( + new Uint8Array(this.data.buffer) + ); + return dst; + } + + copyTo(dst: FakeTensor, options?: { offset?: number; length?: number }): FakeTensor { + this.assertLive('copyTo'); + dst.assertLive('copyTo'); + if (dst.dtype !== this.dtype) { + throw new Error(`copyTo: dtype mismatch ('${this.dtype}' -> '${dst.dtype}')`); + } + const offset = options?.offset ?? 0; + const length = options?.length ?? this.numel - offset; + if (offset < 0 || length < 0 || offset + length > this.numel) { + throw new Error(`copyTo: window [${offset}, ${offset + length}) is out of bounds`); + } + if (length > dst.numel) { + throw new Error(`copyTo: destination holds ${dst.numel} elements, need ${length}`); + } + for (let i = 0; i < length; i++) dst.setElement(i, this.getElement(offset + i)); + return dst; + } + + through(fn: (t: FakeTensor, ...args: Args) => R, ...args: Args): R { + this.assertLive('through'); + return fn(this, ...args); + } + + throughIf( + pred: boolean, + fn: (t: FakeTensor, ...args: Args) => FakeTensor, + ...args: Args + ): FakeTensor { + return pred ? this.through(fn, ...args) : this; + } + + dispose(): void { + if (this.disposed) { + doubleDisposes++; + return; + } + this.disposed = true; + live.delete(this.id); + } +} + +export const tensorTracker = { + /** @returns How many tensors are currently allocated. */ + liveCount(): number { + return live.size; + }, + /** @returns A readable description of every tensor still allocated. */ + liveDescriptions(): string[] { + return [...live.values()].map((t) => `#${t.id} ${t.dtype}[${t.shape}]`); + }, + /** @returns How many times `dispose()` was called on an already-disposed tensor. */ + doubleDisposeCount(): number { + return doubleDisposes; + }, + /** Forgets all tracked tensors — call between tests. */ + reset(): void { + live.clear(); + doubleDisposes = 0; + }, +}; diff --git a/packages/react-native-executorch/__tests__/support/fixtures.ts b/packages/react-native-executorch/__tests__/support/fixtures.ts new file mode 100644 index 0000000000..99d4648686 --- /dev/null +++ b/packages/react-native-executorch/__tests__/support/fixtures.ts @@ -0,0 +1,96 @@ +/** + * Builders for the inputs the task pipelines take: exported model schemas, + * image buffers, and `execute` implementations that write known values into + * the pre-allocated output tensors. + */ +import type { ConcreteDim, MethodSpec, ModelSpec, SymbolicDim } from '../../src/core/schema'; +import type { ImageBuffer, ImageFormat } from '../../src/extensions/cv/image'; +import type { FakeExecute } from './fakeJsi'; +import type { FakeTensor } from './fakeTensor'; + +/** + * Reinterprets a spec built with the library's own `method`/`f32`/`i64` + * helpers as an *exported* spec. + * + * Fixtures are written with the same helpers a pipeline author uses, which + * type as `SymbolicDim`. Every dimension is checked to be concrete before the + * cast, so the assertion cannot quietly hide a stray `StaticDim('H')`. + * @param spec The spec to reinterpret. + * @returns The same object, typed as an exported model spec. + */ +export function exported(spec: Record>): ModelSpec { + for (const [methodName, methodSpec] of Object.entries(spec)) { + for (const param of [...methodSpec.inputs, ...methodSpec.outputs]) { + if (param.kind !== 'Tensor') continue; + for (const dim of param.shape) { + if (dim.kind === 'static' || dim.kind === 'dynamic') { + throw new Error( + `exported(): method '${methodName}' still has the symbolic dimension '${dim.symbol}'` + ); + } + } + } + } + return spec as ModelSpec; +} + +/** + * Builds an RGB image buffer whose pixels are a deterministic function of + * their coordinates, so a preprocessing result can be checked against an + * independently computed expectation. + * @param width Image width in pixels. + * @param height Image height in pixels. + * @param format Pixel format. Defaults to `'rgb'`. + * @returns The image buffer. + */ +export function imageBuffer( + width: number, + height: number, + format: ImageFormat = 'rgb' +): ImageBuffer { + const channels = { rgb: 3, bgr: 3, rgba: 4, bgra: 4, gray: 1 }[format]; + const data = new Uint8Array(width * height * channels); + for (let i = 0; i < data.length; i++) data[i] = (i * 7) % 256; + return { data, width, height, format, layout: 'hwc' }; +} + +/** + * An `execute` that writes `values[i]` into output tensor `i`, element by + * element. Shorter arrays leave the remaining elements at zero. + * @param values Per-output element values. + * @returns The execute implementation. + */ +export function writesOutputs(...values: readonly (readonly number[])[]): FakeExecute { + return (_methodName, _inputs, outputs) => { + outputs.forEach((output, index) => { + const source = values[index]; + if (!source) return; + for (let i = 0; i < Math.min(source.length, output.numel); i++) { + output.setElement(i, source[i]!); + } + }); + }; +} + +/** + * An `execute` that copies its first input tensor into its first output — an + * identity model, useful for asserting what preprocessing produced. + * @returns The execute implementation. + */ +export function copiesInputToOutput(): FakeExecute { + return (_methodName, inputs, outputs) => { + const input = inputs[0] as FakeTensor | undefined; + const output = outputs[0]; + if (!input || !output) return; + for (let i = 0; i < Math.min(input.numel, output.numel); i++) { + output.setElement(i, input.getElement(i)); + } + }; +} + +/** Preprocessor options every CV task fixture shares. */ +export const STRETCH_PREPROCESSING = { + resizeMode: 'stretch', + interpolation: 'linear', + normalizeOpts: { alpha: 1 / 255, beta: 0 }, +} as const; diff --git a/packages/react-native-executorch/__tests__/support/lifetime.ts b/packages/react-native-executorch/__tests__/support/lifetime.ts new file mode 100644 index 0000000000..acabb15c74 --- /dev/null +++ b/packages/react-native-executorch/__tests__/support/lifetime.ts @@ -0,0 +1,36 @@ +/** + * Automatic disposal for pipelines a test creates. + * + * Without it, an assertion that fails before the test reaches its `dispose()` + * call trips the global leak check too, and the real failure ends up buried + * under a second, misleading error. Wrapping construction in `tracked()` keeps + * the reported failure to the one that matters. + * + * Disposal is idempotent, so tests that assert on `dispose()` explicitly can + * still call it themselves. + */ +type Disposable = { dispose: () => void }; + +const created: Disposable[] = []; + +/** + * Registers `instance` for disposal at the end of the current test. + * @typeParam T The pipeline type. + * @param instance The pipeline to track. + * @returns The same instance. + */ +export function tracked(instance: T): T { + created.push(instance); + return instance; +} + +/** Disposes everything tracked in the current test. Called from the setup file. */ +export function disposeTracked(): void { + for (const instance of created.splice(0)) { + try { + instance.dispose(); + } catch { + // A pipeline that fails to dispose is reported by the leak check instead. + } + } +} diff --git a/packages/react-native-executorch/__tests__/support/setup.ts b/packages/react-native-executorch/__tests__/support/setup.ts new file mode 100644 index 0000000000..b55026d923 --- /dev/null +++ b/packages/react-native-executorch/__tests__/support/setup.ts @@ -0,0 +1,63 @@ +/** + * Jest setup: installs the fake native runtime and resets every piece of + * shared state between tests. + * + * This runs before each test file is evaluated, which matters because + * `src/native/bridge.ts` throws at import time when `__rnexecutorch_jsi__` is + * missing — so the global has to exist before the first `import` of any `src/` + * module is resolved. + */ +import { cleanup } from '@testing-library/react-native'; + +import { fakeJsi, installFakeJsi } from './fakeJsi'; +import { fakeFetch, fakeFs, fakeNet } from './blobUtilMock'; +import { disposeTracked } from './lifetime'; + +installFakeJsi(); + +let leakCheckEnabled = true; + +/** + * Opts the current test out of the automatic native-leak assertion. Use it + * when a test deliberately abandons a pipeline without disposing it. + */ +export function allowNativeLeaks(): void { + leakCheckEnabled = false; +} + +beforeEach(() => { + leakCheckEnabled = true; + fakeJsi.reset(); + fakeFs.reset(); + fakeNet.reset(); + globalThis.fetch = fakeFetch as unknown as typeof globalThis.fetch; +}); + +// Native memory is not garbage collected, so anything a test allocates through +// the fake and does not dispose is a leak in the code under test. Checking it +// globally means every pipeline suite gets disposal coverage for free. +afterEach(async () => { + // Unmount any rendered hook before the leak check: a component still mounted + // is still holding its pipeline, which would read as a leak. React Native + // Testing Library's own auto-cleanup runs after this hook, too late to help. + await cleanup(); + disposeTracked(); + if (!leakCheckEnabled) return; + + const tensors = fakeJsi.liveTensorDescriptions(); + const models = fakeJsi.liveModels(); + const tokenizers = fakeJsi.liveTokenizers(); + if (tensors.length === 0 && models.length === 0 && tokenizers.length === 0) return; + + throw new Error( + [ + 'Native resources were left undisposed by this test:', + tensors.length > 0 ? ` tensors: ${tensors.join(', ')}` : '', + models.length > 0 ? ` models: ${models.join(', ')}` : '', + tokenizers.length > 0 ? ` tokenizers: ${tokenizers.join(', ')}` : '', + 'Dispose the pipeline, or call allowNativeLeaks() if the leak is the point of the test.', + ] + .filter(Boolean) + .join('\n') + ); +}); diff --git a/packages/react-native-executorch/__tests__/support/workletsMock.ts b/packages/react-native-executorch/__tests__/support/workletsMock.ts new file mode 100644 index 0000000000..2d41a6de80 --- /dev/null +++ b/packages/react-native-executorch/__tests__/support/workletsMock.ts @@ -0,0 +1,65 @@ +/** + * Mock of `react-native-worklets`. + * + * The real package needs a native worklet runtime, and `src/core/runtime.ts` + * calls `createWorkletRuntime` at module scope — importing anything from `src/` + * would crash without this. Since a worklet is an ordinary JS function that has + * been marked for a second runtime, running it inline on the test's own thread + * exercises exactly the same code; the only thing not covered is the thread + * hop itself, which belongs to react-native-worklets rather than to this + * library. + */ + +/** Stand-in for the opaque native runtime handle. */ +export type WorkletRuntime = { readonly name: string }; + +export const createWorkletRuntime = (config?: { name?: string }): WorkletRuntime => ({ + name: config?.name ?? 'FakeWorkletRuntime', +}); + +/** + * Runs `fn` inline and resolves with its result. Kept async so callers still + * observe a microtask boundary, the way the real dispatch does. + * @param _runtime Ignored — there is only one thread here. + * @param fn The worklet to run. + * @param args Arguments forwarded to `fn`. + * @returns A promise resolving to `fn`'s return value. + */ +export const runOnRuntimeAsync = async ( + _runtime: WorkletRuntime, + fn: (args: Args) => R, + args: Args +): Promise => fn(args); + +/** + * Schedules `fn` back on the "RN thread" — inline here, but deferred to a + * macrotask so a caller that expects it not to run synchronously still holds. + * @param fn The callback to schedule. + * @param args Arguments forwarded to `fn`. + */ +export const scheduleOnRN = ( + fn: (...args: Args) => unknown, + ...args: Args +): void => { + setTimeout(() => fn(...args), 0); +}; + +/** + * Minimal single-threaded stand-in for a `Synchronizable`. With one thread + * there is nothing to synchronize, so this is a plain boxed value. + * @typeParam T The boxed value type. + * @param initial The initial value. + * @returns The synchronizable box. + */ +export const createSynchronizable = (initial: T) => { + let value = initial; + return { + getDirty: () => value, + getBlocking: () => value, + setBlocking: (next: T | ((prev: T) => T)) => { + value = typeof next === 'function' ? (next as (prev: T) => T)(value) : next; + }, + lock: () => {}, + unlock: () => {}, + }; +}; diff --git a/packages/react-native-executorch/jest.config.js b/packages/react-native-executorch/jest.config.js new file mode 100644 index 0000000000..983f84f3bd --- /dev/null +++ b/packages/react-native-executorch/jest.config.js @@ -0,0 +1,32 @@ +/** + * Jest configuration for the TypeScript API test suites under `__tests__/`. + * + * See `__tests__/README.md` for what these suites cover and why the native + * boundary is faked rather than stubbed. + */ +module.exports = { + preset: 'react-native', + rootDir: '.', + roots: ['/__tests__'], + testMatch: ['**/*.test.ts', '**/*.test.tsx'], + + // The fake JSI runtime has to be installed before any `src/` module is + // imported: `src/native/bridge.ts` throws at import time when the + // `__rnexecutorch_jsi__` global is missing. `setupFilesAfterEnv` runs before + // the test file (and therefore before its imports) are evaluated. + setupFilesAfterEnv: ['/__tests__/support/setup.ts'], + + // Mapped explicitly rather than relying on `__mocks__` auto-mocking: both + // packages are hoisted to the monorepo root, so which `__mocks__` directory + // sits "adjacent to node_modules" is not something this package controls. + moduleNameMapper: { + '^react-native-blob-util$': '/__tests__/support/blobUtilMock.ts', + '^react-native-worklets$': '/__tests__/support/workletsMock.ts', + }, + + // Snapshot of the public export surface — see `api/apiSurface.test.ts`. + snapshotFormat: { escapeString: false, printBasicPrototype: false }, + + clearMocks: true, + restoreMocks: true, +}; diff --git a/packages/react-native-executorch/package.json b/packages/react-native-executorch/package.json index 9311d4952c..dd12008aed 100644 --- a/packages/react-native-executorch/package.json +++ b/packages/react-native-executorch/package.json @@ -43,6 +43,7 @@ "scripts": { "clean": "del-cli lib", "prepare": "bob build", + "test": "jest", "typecheck": "tsc --noEmit", "lint": "eslint \"**/*.{js,ts,tsx}\"", "lint:cpp": "./scripts/clang-tidy.sh", @@ -92,13 +93,17 @@ "@babel/core": "^7.25.1", "@react-native/babel-preset": "0.83.6", "@react-native/metro-config": "^0.86.0", + "@testing-library/react-native": "^14.0.1", + "@types/jest": "^29.5.14", "@types/react": "^19.1.12", "del-cli": "^6.0.0", + "jest": "^29.7.0", "react": "19.2.0", "react-native": "0.83.6", "react-native-blob-util": "^0.24.0", "react-native-builder-bob": "^0.40.18", "react-native-worklets": "0.9.1", + "test-renderer": "^1.2.0", "typescript": "~5.9.2" }, "react-native-builder-bob": { diff --git a/packages/react-native-executorch/tsconfig.build.json b/packages/react-native-executorch/tsconfig.build.json index 3c0636adf2..cf63e46cf5 100644 --- a/packages/react-native-executorch/tsconfig.build.json +++ b/packages/react-native-executorch/tsconfig.build.json @@ -1,4 +1,4 @@ { "extends": "./tsconfig", - "exclude": ["example", "lib"] + "exclude": ["example", "lib", "__tests__", "jest.config.js"] } diff --git a/yarn.lock b/yarn.lock index c3ada1c5a0..2477f749df 100644 --- a/yarn.lock +++ b/yarn.lock @@ -87,7 +87,7 @@ __metadata: languageName: node linkType: hard -"@babel/core@npm:^7.21.3, @babel/core@npm:^7.29.0": +"@babel/core@npm:^7.21.3, @babel/core@npm:^7.23.9, @babel/core@npm:^7.29.0": version: 7.29.7 resolution: "@babel/core@npm:7.29.7" dependencies: @@ -150,6 +150,19 @@ __metadata: languageName: node linkType: hard +"@babel/generator@npm:^7.7.2": + version: 7.29.8 + resolution: "@babel/generator@npm:7.29.8" + dependencies: + "@babel/parser": "npm:^7.29.8" + "@babel/types": "npm:^7.29.8" + "@jridgewell/gen-mapping": "npm:^0.3.12" + "@jridgewell/trace-mapping": "npm:^0.3.28" + jsesc: "npm:^3.0.2" + checksum: 10/679c3d1302936f391260acee4059e0c3cd5bff6341772f0b85bb142feab1a253d0e155aad0e9c8531e024d6239a2cc5169d1e294c6890901e7d4ab0f7c9eaaaa + languageName: node + linkType: hard + "@babel/helper-annotate-as-pure@npm:^7.27.1, @babel/helper-annotate-as-pure@npm:^7.27.3": version: 7.27.3 resolution: "@babel/helper-annotate-as-pure@npm:7.27.3" @@ -511,6 +524,17 @@ __metadata: languageName: node linkType: hard +"@babel/parser@npm:^7.23.9, @babel/parser@npm:^7.29.8": + version: 7.29.8 + resolution: "@babel/parser@npm:7.29.8" + dependencies: + "@babel/types": "npm:^7.29.8" + bin: + parser: ./bin/babel-parser.js + checksum: 10/dbd14ebbba0fa3019acd2712b0db3ffd594d0850d4fc487667287b5702893f54a7911570ea842f0cff4dc492a2412e3287dfabfe00c6b78efa7becb1255f3f86 + languageName: node + linkType: hard + "@babel/parser@npm:^7.29.7": version: 7.29.7 resolution: "@babel/parser@npm:7.29.7" @@ -757,7 +781,7 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-syntax-jsx@npm:^7.29.7": +"@babel/plugin-syntax-jsx@npm:^7.29.7, @babel/plugin-syntax-jsx@npm:^7.7.2": version: 7.29.7 resolution: "@babel/plugin-syntax-jsx@npm:7.29.7" dependencies: @@ -867,7 +891,7 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-syntax-typescript@npm:^7.29.7": +"@babel/plugin-syntax-typescript@npm:^7.29.7, @babel/plugin-syntax-typescript@npm:^7.7.2": version: 7.29.7 resolution: "@babel/plugin-syntax-typescript@npm:7.29.7" dependencies: @@ -1876,6 +1900,23 @@ __metadata: languageName: node linkType: hard +"@babel/types@npm:^7.29.8": + version: 7.29.8 + resolution: "@babel/types@npm:7.29.8" + dependencies: + "@babel/helper-string-parser": "npm:^7.29.7" + "@babel/helper-validator-identifier": "npm:^7.29.7" + checksum: 10/ff2becf0f8b432f0820f286fb9319d7f3819b2d0eb95bc26957fbc6250a3ca0ae3656feb98ecb5f171f9e04b34a4c08f5036447f17459ed7bfc32ff649d9b71e + languageName: node + linkType: hard + +"@bcoe/v8-coverage@npm:^0.2.3": + version: 0.2.3 + resolution: "@bcoe/v8-coverage@npm:0.2.3" + checksum: 10/1a1f0e356a3bb30b5f1ced6f79c413e6ebacf130421f15fac5fcd8be5ddf98aedb4404d7f5624e3285b700e041f9ef938321f3ca4d359d5b716f96afa120d88d + languageName: node + linkType: hard + "@cspell/cspell-bundled-dicts@npm:8.19.4": version: 8.19.4 resolution: "@cspell/cspell-bundled-dicts@npm:8.19.4" @@ -3437,6 +3478,68 @@ __metadata: languageName: node linkType: hard +"@istanbuljs/schema@npm:^0.1.3": + version: 0.1.6 + resolution: "@istanbuljs/schema@npm:0.1.6" + checksum: 10/966e1a80b0e52170d4b3b9fa75e1aa5f2cf01138416c828c249dcfc75706a32b13022dc8d06b7aab6ea6a80b63927d3e546ad04f005188fef20b3d2cbbf2b229 + languageName: node + linkType: hard + +"@jest/console@npm:^29.7.0": + version: 29.7.0 + resolution: "@jest/console@npm:29.7.0" + dependencies: + "@jest/types": "npm:^29.6.3" + "@types/node": "npm:*" + chalk: "npm:^4.0.0" + jest-message-util: "npm:^29.7.0" + jest-util: "npm:^29.7.0" + slash: "npm:^3.0.0" + checksum: 10/4a80c750e8a31f344233cb9951dee9b77bf6b89377cb131f8b3cde07ff218f504370133a5963f6a786af4d2ce7f85642db206ff7a15f99fe58df4c38ac04899e + languageName: node + linkType: hard + +"@jest/core@npm:^29.7.0": + version: 29.7.0 + resolution: "@jest/core@npm:29.7.0" + dependencies: + "@jest/console": "npm:^29.7.0" + "@jest/reporters": "npm:^29.7.0" + "@jest/test-result": "npm:^29.7.0" + "@jest/transform": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" + "@types/node": "npm:*" + ansi-escapes: "npm:^4.2.1" + chalk: "npm:^4.0.0" + ci-info: "npm:^3.2.0" + exit: "npm:^0.1.2" + graceful-fs: "npm:^4.2.9" + jest-changed-files: "npm:^29.7.0" + jest-config: "npm:^29.7.0" + jest-haste-map: "npm:^29.7.0" + jest-message-util: "npm:^29.7.0" + jest-regex-util: "npm:^29.6.3" + jest-resolve: "npm:^29.7.0" + jest-resolve-dependencies: "npm:^29.7.0" + jest-runner: "npm:^29.7.0" + jest-runtime: "npm:^29.7.0" + jest-snapshot: "npm:^29.7.0" + jest-util: "npm:^29.7.0" + jest-validate: "npm:^29.7.0" + jest-watcher: "npm:^29.7.0" + micromatch: "npm:^4.0.4" + pretty-format: "npm:^29.7.0" + slash: "npm:^3.0.0" + strip-ansi: "npm:^6.0.0" + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + checksum: 10/ab6ac2e562d083faac7d8152ec1cc4eccc80f62e9579b69ed40aedf7211a6b2d57024a6cd53c4e35fd051c39a236e86257d1d99ebdb122291969a0a04563b51e + languageName: node + linkType: hard + "@jest/create-cache-key-function@npm:^29.7.0": version: 29.7.0 resolution: "@jest/create-cache-key-function@npm:29.7.0" @@ -3446,6 +3549,13 @@ __metadata: languageName: node linkType: hard +"@jest/diff-sequences@npm:30.4.0": + version: 30.4.0 + resolution: "@jest/diff-sequences@npm:30.4.0" + checksum: 10/65c27937c10a7157899dad5d176806104286f9d55464f318955a0cee98db8aed6b8f70ad4aee7133468087146422cdd391d49b1e101ec543db3283ee4eb59c06 + languageName: node + linkType: hard + "@jest/environment@npm:^29.7.0": version: 29.7.0 resolution: "@jest/environment@npm:29.7.0" @@ -3458,6 +3568,25 @@ __metadata: languageName: node linkType: hard +"@jest/expect-utils@npm:^29.7.0": + version: 29.7.0 + resolution: "@jest/expect-utils@npm:29.7.0" + dependencies: + jest-get-type: "npm:^29.6.3" + checksum: 10/ef8d379778ef574a17bde2801a6f4469f8022a46a5f9e385191dc73bb1fc318996beaed4513fbd7055c2847227a1bed2469977821866534593a6e52a281499ee + languageName: node + linkType: hard + +"@jest/expect@npm:^29.7.0": + version: 29.7.0 + resolution: "@jest/expect@npm:29.7.0" + dependencies: + expect: "npm:^29.7.0" + jest-snapshot: "npm:^29.7.0" + checksum: 10/fea6c3317a8da5c840429d90bfe49d928e89c9e89fceee2149b93a11b7e9c73d2f6e4d7cdf647163da938fc4e2169e4490be6bae64952902bc7a701033fd4880 + languageName: node + linkType: hard + "@jest/fake-timers@npm:^29.7.0": version: 29.7.0 resolution: "@jest/fake-timers@npm:29.7.0" @@ -3472,6 +3601,71 @@ __metadata: languageName: node linkType: hard +"@jest/get-type@npm:30.1.0": + version: 30.1.0 + resolution: "@jest/get-type@npm:30.1.0" + checksum: 10/e2a95fbb49ce2d15547db8af5602626caf9b05f62a5e583b4a2de9bd93a2bfe7175f9bbb2b8a5c3909ce261d467b6991d7265bb1d547cb60e7e97f571f361a70 + languageName: node + linkType: hard + +"@jest/globals@npm:^29.7.0": + version: 29.7.0 + resolution: "@jest/globals@npm:29.7.0" + dependencies: + "@jest/environment": "npm:^29.7.0" + "@jest/expect": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" + jest-mock: "npm:^29.7.0" + checksum: 10/97dbb9459135693ad3a422e65ca1c250f03d82b2a77f6207e7fa0edd2c9d2015fbe4346f3dc9ebff1678b9d8da74754d4d440b7837497f8927059c0642a22123 + languageName: node + linkType: hard + +"@jest/reporters@npm:^29.7.0": + version: 29.7.0 + resolution: "@jest/reporters@npm:29.7.0" + dependencies: + "@bcoe/v8-coverage": "npm:^0.2.3" + "@jest/console": "npm:^29.7.0" + "@jest/test-result": "npm:^29.7.0" + "@jest/transform": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" + "@jridgewell/trace-mapping": "npm:^0.3.18" + "@types/node": "npm:*" + chalk: "npm:^4.0.0" + collect-v8-coverage: "npm:^1.0.0" + exit: "npm:^0.1.2" + glob: "npm:^7.1.3" + graceful-fs: "npm:^4.2.9" + istanbul-lib-coverage: "npm:^3.0.0" + istanbul-lib-instrument: "npm:^6.0.0" + istanbul-lib-report: "npm:^3.0.0" + istanbul-lib-source-maps: "npm:^4.0.0" + istanbul-reports: "npm:^3.1.3" + jest-message-util: "npm:^29.7.0" + jest-util: "npm:^29.7.0" + jest-worker: "npm:^29.7.0" + slash: "npm:^3.0.0" + string-length: "npm:^4.0.1" + strip-ansi: "npm:^6.0.0" + v8-to-istanbul: "npm:^9.0.1" + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + checksum: 10/a17d1644b26dea14445cedd45567f4ba7834f980be2ef74447204e14238f121b50d8b858fde648083d2cd8f305f81ba434ba49e37a5f4237a6f2a61180cc73dc + languageName: node + linkType: hard + +"@jest/schemas@npm:30.4.1": + version: 30.4.1 + resolution: "@jest/schemas@npm:30.4.1" + dependencies: + "@sinclair/typebox": "npm:^0.34.0" + checksum: 10/86e62c8fd8fc77535085f1ede3a416430a3740f78b8f88ec7d0ee4516b22daf3326ffc1ade9d5f7839bbde923aaf1b5ac430a42ed4bb1a38edc3de5005a58f51 + languageName: node + linkType: hard + "@jest/schemas@npm:^29.6.3": version: 29.6.3 resolution: "@jest/schemas@npm:29.6.3" @@ -3481,6 +3675,41 @@ __metadata: languageName: node linkType: hard +"@jest/source-map@npm:^29.6.3": + version: 29.6.3 + resolution: "@jest/source-map@npm:29.6.3" + dependencies: + "@jridgewell/trace-mapping": "npm:^0.3.18" + callsites: "npm:^3.0.0" + graceful-fs: "npm:^4.2.9" + checksum: 10/bcc5a8697d471396c0003b0bfa09722c3cd879ad697eb9c431e6164e2ea7008238a01a07193dfe3cbb48b1d258eb7251f6efcea36f64e1ebc464ea3c03ae2deb + languageName: node + linkType: hard + +"@jest/test-result@npm:^29.7.0": + version: 29.7.0 + resolution: "@jest/test-result@npm:29.7.0" + dependencies: + "@jest/console": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" + "@types/istanbul-lib-coverage": "npm:^2.0.0" + collect-v8-coverage: "npm:^1.0.0" + checksum: 10/c073ab7dfe3c562bff2b8fee6cc724ccc20aa96bcd8ab48ccb2aa309b4c0c1923a9e703cea386bd6ae9b71133e92810475bb9c7c22328fc63f797ad3324ed189 + languageName: node + linkType: hard + +"@jest/test-sequencer@npm:^29.7.0": + version: 29.7.0 + resolution: "@jest/test-sequencer@npm:29.7.0" + dependencies: + "@jest/test-result": "npm:^29.7.0" + graceful-fs: "npm:^4.2.9" + jest-haste-map: "npm:^29.7.0" + slash: "npm:^3.0.0" + checksum: 10/4420c26a0baa7035c5419b0892ff8ffe9a41b1583ec54a10db3037cd46a7e29dd3d7202f8aa9d376e9e53be5f8b1bc0d16e1de6880a6d319b033b01dc4c8f639 + languageName: node + linkType: hard + "@jest/transform@npm:^29.7.0": version: 29.7.0 resolution: "@jest/transform@npm:29.7.0" @@ -3562,7 +3791,7 @@ __metadata: languageName: node linkType: hard -"@jridgewell/trace-mapping@npm:^0.3.18, @jridgewell/trace-mapping@npm:^0.3.24, @jridgewell/trace-mapping@npm:^0.3.25, @jridgewell/trace-mapping@npm:^0.3.28": +"@jridgewell/trace-mapping@npm:^0.3.12, @jridgewell/trace-mapping@npm:^0.3.18, @jridgewell/trace-mapping@npm:^0.3.24, @jridgewell/trace-mapping@npm:^0.3.25, @jridgewell/trace-mapping@npm:^0.3.28": version: 0.3.31 resolution: "@jridgewell/trace-mapping@npm:0.3.31" dependencies: @@ -4898,6 +5127,13 @@ __metadata: languageName: node linkType: hard +"@sinclair/typebox@npm:^0.34.0": + version: 0.34.52 + resolution: "@sinclair/typebox@npm:0.34.52" + checksum: 10/693a7ab6c8935cbb6e89698bd75ac465038ecfff8b8ad22c50ad6d93a049b0e7cfe4d759200d0401707c5679d6f7a3791fd6dee139bcbb250fa3aae3c17e8bd1 + languageName: node + linkType: hard + "@sindresorhus/base62@npm:^1.0.0": version: 1.0.0 resolution: "@sindresorhus/base62@npm:1.0.0" @@ -5084,6 +5320,26 @@ __metadata: languageName: node linkType: hard +"@testing-library/react-native@npm:^14.0.1": + version: 14.0.1 + resolution: "@testing-library/react-native@npm:14.0.1" + dependencies: + jest-matcher-utils: "npm:^30.4.1" + picocolors: "npm:^1.1.1" + pretty-format: "npm:^30.4.1" + redent: "npm:^3.0.0" + peerDependencies: + jest: ">=29.0.0" + react: ">=19.0.0" + react-native: ">=0.78" + test-renderer: ^1.0.0 + peerDependenciesMeta: + jest: + optional: true + checksum: 10/6f65b9566cce6f9737c3fff8beb0b6e7b623f3e440f286b0d652e2a8ae58eec854ec455ac297f6c736a3f7c289869c5f9fe07eddf4286f95751934a4fd27c3ee + languageName: node + linkType: hard + "@testing-library/user-event@npm:^14.6.1": version: 14.6.1 resolution: "@testing-library/user-event@npm:14.6.1" @@ -5166,7 +5422,7 @@ __metadata: languageName: node linkType: hard -"@types/istanbul-lib-coverage@npm:*, @types/istanbul-lib-coverage@npm:^2.0.0": +"@types/istanbul-lib-coverage@npm:*, @types/istanbul-lib-coverage@npm:^2.0.0, @types/istanbul-lib-coverage@npm:^2.0.1": version: 2.0.6 resolution: "@types/istanbul-lib-coverage@npm:2.0.6" checksum: 10/3feac423fd3e5449485afac999dcfcb3d44a37c830af898b689fadc65d26526460bedb889db278e0d4d815a670331796494d073a10ee6e3a6526301fe7415778 @@ -5191,6 +5447,16 @@ __metadata: languageName: node linkType: hard +"@types/jest@npm:^29.5.14": + version: 29.5.14 + resolution: "@types/jest@npm:29.5.14" + dependencies: + expect: "npm:^29.0.0" + pretty-format: "npm:^29.0.0" + checksum: 10/59ec7a9c4688aae8ee529316c43853468b6034f453d08a2e1064b281af9c81234cec986be796288f1bbb29efe943bc950e70c8fa8faae1e460d50e3cf9760f9b + languageName: node + linkType: hard + "@types/json-schema@npm:^7.0.9": version: 7.0.15 resolution: "@types/json-schema@npm:7.0.15" @@ -5232,6 +5498,15 @@ __metadata: languageName: node linkType: hard +"@types/react-reconciler@npm:~0.33.0": + version: 0.33.0 + resolution: "@types/react-reconciler@npm:0.33.0" + peerDependencies: + "@types/react": "*" + checksum: 10/b65408e08f9e9924a6040fb7a4820ed12efdad598dccb9e08efc70a7101199cce4a5aa1f8695a82d8efe7b503bd9b8dc5122db8a23bac39f9bc473d595873ba4 + languageName: node + linkType: hard + "@types/react-test-renderer@npm:^19.1.0": version: 19.1.0 resolution: "@types/react-test-renderer@npm:19.1.0" @@ -5666,7 +5941,7 @@ __metadata: languageName: node linkType: hard -"ansi-styles@npm:^5.0.0": +"ansi-styles@npm:^5.0.0, ansi-styles@npm:^5.2.0": version: 5.2.0 resolution: "ansi-styles@npm:5.2.0" checksum: 10/d7f4e97ce0623aea6bc0d90dcd28881ee04cba06c570b97fd3391bd7a268eedfd9d5e2dd4fdcbdd82b8105df5faf6f24aaedc08eaf3da898e702db5948f63469 @@ -6515,6 +6790,13 @@ __metadata: languageName: node linkType: hard +"char-regex@npm:^1.0.2": + version: 1.0.2 + resolution: "char-regex@npm:1.0.2" + checksum: 10/1ec5c2906adb9f84e7f6732a40baef05d7c85401b82ffcbc44b85fbd0f7a2b0c2a96f2eb9cf55cae3235dc12d4023003b88f09bcae8be9ae894f52ed746f4d48 + languageName: node + linkType: hard + "character-entities-legacy@npm:^1.0.0": version: 1.1.4 resolution: "character-entities-legacy@npm:1.1.4" @@ -6605,6 +6887,13 @@ __metadata: languageName: node linkType: hard +"cjs-module-lexer@npm:^1.0.0": + version: 1.4.3 + resolution: "cjs-module-lexer@npm:1.4.3" + checksum: 10/d2b92f919a2dedbfd61d016964fce8da0035f827182ed6839c97cac56e8a8077cfa6a59388adfe2bc588a19cef9bbe830d683a76a6e93c51f65852062cfe2591 + languageName: node + linkType: hard + "clean-stack@npm:^2.0.0": version: 2.2.0 resolution: "clean-stack@npm:2.2.0" @@ -6663,6 +6952,20 @@ __metadata: languageName: node linkType: hard +"co@npm:^4.6.0": + version: 4.6.0 + resolution: "co@npm:4.6.0" + checksum: 10/a5d9f37091c70398a269e625cedff5622f200ed0aa0cff22ee7b55ed74a123834b58711776eb0f1dc58eb6ebbc1185aa7567b57bd5979a948c6e4f85073e2c05 + languageName: node + linkType: hard + +"collect-v8-coverage@npm:^1.0.0": + version: 1.0.3 + resolution: "collect-v8-coverage@npm:1.0.3" + checksum: 10/656443261fb7b79cf79e89cba4b55622b07c1d4976c630829d7c5c585c73cda1c2ff101f316bfb19bb9e2c58d724c7db1f70a21e213dcd14099227c5e6019860 + languageName: node + linkType: hard + "color-convert@npm:^1.9.0": version: 1.9.3 resolution: "color-convert@npm:1.9.3" @@ -6880,6 +7183,23 @@ __metadata: languageName: node linkType: hard +"create-jest@npm:^29.7.0": + version: 29.7.0 + resolution: "create-jest@npm:29.7.0" + dependencies: + "@jest/types": "npm:^29.6.3" + chalk: "npm:^4.0.0" + exit: "npm:^0.1.2" + graceful-fs: "npm:^4.2.9" + jest-config: "npm:^29.7.0" + jest-util: "npm:^29.7.0" + prompts: "npm:^2.0.1" + bin: + create-jest: bin/create-jest.js + checksum: 10/847b4764451672b4174be4d5c6d7d63442ec3aa5f3de52af924e4d996d87d7801c18e125504f25232fc75840f6625b3ac85860fac6ce799b5efae7bdcaf4a2b7 + languageName: node + linkType: hard + "cross-spawn@npm:^7.0.0, cross-spawn@npm:^7.0.2, cross-spawn@npm:^7.0.3, cross-spawn@npm:^7.0.6": version: 7.0.6 resolution: "cross-spawn@npm:7.0.6" @@ -7145,7 +7465,7 @@ __metadata: languageName: node linkType: hard -"debug@npm:4, debug@npm:^4.0.0, debug@npm:^4.1.0, debug@npm:^4.3.1, debug@npm:^4.3.2, debug@npm:^4.3.4, debug@npm:^4.3.5, debug@npm:^4.4.0, debug@npm:^4.4.3": +"debug@npm:4, debug@npm:^4.0.0, debug@npm:^4.1.0, debug@npm:^4.1.1, debug@npm:^4.3.1, debug@npm:^4.3.2, debug@npm:^4.3.4, debug@npm:^4.3.5, debug@npm:^4.4.0, debug@npm:^4.4.3": version: 4.4.3 resolution: "debug@npm:4.4.3" dependencies: @@ -7189,6 +7509,18 @@ __metadata: languageName: node linkType: hard +"dedent@npm:^1.0.0": + version: 1.7.2 + resolution: "dedent@npm:1.7.2" + peerDependencies: + babel-plugin-macros: ^3.1.0 + peerDependenciesMeta: + babel-plugin-macros: + optional: true + checksum: 10/30b9062290dca72b0f5a6cd3667633448cef8cd0dec602eab61015741269ad49df90cabf0521f9a32d134ceab4e21aa7f097258c55cc3baadef94874686d6480 + languageName: node + linkType: hard + "deep-is@npm:^0.1.3": version: 0.1.4 resolution: "deep-is@npm:0.1.4" @@ -7196,7 +7528,7 @@ __metadata: languageName: node linkType: hard -"deepmerge@npm:^4.3.1": +"deepmerge@npm:^4.2.2, deepmerge@npm:^4.3.1": version: 4.3.1 resolution: "deepmerge@npm:4.3.1" checksum: 10/058d9e1b0ff1a154468bf3837aea436abcfea1ba1d165ddaaf48ca93765fdd01a30d33c36173da8fbbed951dd0a267602bc782fe288b0fc4b7e1e7091afc4529 @@ -7306,6 +7638,13 @@ __metadata: languageName: node linkType: hard +"detect-newline@npm:^3.0.0": + version: 3.1.0 + resolution: "detect-newline@npm:3.1.0" + checksum: 10/ae6cd429c41ad01b164c59ea36f264a2c479598e61cba7c99da24175a7ab80ddf066420f2bec9a1c57a6bead411b4655ff15ad7d281c000a89791f48cbe939e7 + languageName: node + linkType: hard + "detect-node-es@npm:^1.1.0": version: 1.1.0 resolution: "detect-node-es@npm:1.1.0" @@ -7322,6 +7661,13 @@ __metadata: languageName: node linkType: hard +"diff-sequences@npm:^29.6.3": + version: 29.6.3 + resolution: "diff-sequences@npm:29.6.3" + checksum: 10/179daf9d2f9af5c57ad66d97cb902a538bcf8ed64963fa7aa0c329b3de3665ce2eb6ffdc2f69f29d445fa4af2517e5e55e5b6e00c00a9ae4f43645f97f7078cb + languageName: node + linkType: hard + "dir-glob@npm:^3.0.1": version: 3.0.1 resolution: "dir-glob@npm:3.0.1" @@ -7443,6 +7789,13 @@ __metadata: languageName: node linkType: hard +"emittery@npm:^0.13.1": + version: 0.13.1 + resolution: "emittery@npm:0.13.1" + checksum: 10/fbe214171d878b924eedf1757badf58a5dce071cd1fa7f620fa841a0901a80d6da47ff05929d53163105e621ce11a71b9d8acb1148ffe1745e045145f6e69521 + languageName: node + linkType: hard + "emoji-regex@npm:^8.0.0": version: 8.0.0 resolution: "emoji-regex@npm:8.0.0" @@ -8057,6 +8410,43 @@ __metadata: languageName: node linkType: hard +"execa@npm:^5.0.0": + version: 5.1.1 + resolution: "execa@npm:5.1.1" + dependencies: + cross-spawn: "npm:^7.0.3" + get-stream: "npm:^6.0.0" + human-signals: "npm:^2.1.0" + is-stream: "npm:^2.0.0" + merge-stream: "npm:^2.0.0" + npm-run-path: "npm:^4.0.1" + onetime: "npm:^5.1.2" + signal-exit: "npm:^3.0.3" + strip-final-newline: "npm:^2.0.0" + checksum: 10/8ada91f2d70f7dff702c861c2c64f21dfdc1525628f3c0454fd6f02fce65f7b958616cbd2b99ca7fa4d474e461a3d363824e91b3eb881705231abbf387470597 + languageName: node + linkType: hard + +"exit@npm:^0.1.2": + version: 0.1.2 + resolution: "exit@npm:0.1.2" + checksum: 10/387555050c5b3c10e7a9e8df5f43194e95d7737c74532c409910e585d5554eaff34960c166643f5e23d042196529daad059c292dcf1fb61b8ca878d3677f4b87 + languageName: node + linkType: hard + +"expect@npm:^29.0.0, expect@npm:^29.7.0": + version: 29.7.0 + resolution: "expect@npm:29.7.0" + dependencies: + "@jest/expect-utils": "npm:^29.7.0" + jest-get-type: "npm:^29.6.3" + jest-matcher-utils: "npm:^29.7.0" + jest-message-util: "npm:^29.7.0" + jest-util: "npm:^29.7.0" + checksum: 10/63f97bc51f56a491950fb525f9ad94f1916e8a014947f8d8445d3847a665b5471b768522d659f5e865db20b6c2033d2ac10f35fcbd881a4d26407a4f6f18451a + languageName: node + linkType: hard + "expo-asset@npm:~56.0.15": version: 56.0.15 resolution: "expo-asset@npm:56.0.15" @@ -8748,7 +9138,7 @@ __metadata: languageName: node linkType: hard -"find-up@npm:^4.1.0": +"find-up@npm:^4.0.0, find-up@npm:^4.1.0": version: 4.1.0 resolution: "find-up@npm:4.1.0" dependencies: @@ -9001,6 +9391,13 @@ __metadata: languageName: node linkType: hard +"get-stream@npm:^6.0.0": + version: 6.0.1 + resolution: "get-stream@npm:6.0.1" + checksum: 10/781266d29725f35c59f1d214aedc92b0ae855800a980800e2923b3fbc4e56b3cb6e462c42e09a1cf1a00c64e056a78fa407cbe06c7c92b7e5cd49b4b85c2a497 + languageName: node + linkType: hard + "get-symbol-description@npm:^1.1.0": version: 1.1.0 resolution: "get-symbol-description@npm:1.1.0" @@ -9354,6 +9751,13 @@ __metadata: languageName: node linkType: hard +"html-escaper@npm:^2.0.0": + version: 2.0.2 + resolution: "html-escaper@npm:2.0.2" + checksum: 10/034d74029dcca544a34fb6135e98d427acd73019796ffc17383eaa3ec2fe1c0471dcbbc8f8ed39e46e86d43ccd753a160631615e4048285e313569609b66d5b7 + languageName: node + linkType: hard + "http-cache-semantics@npm:^4.1.1": version: 4.2.0 resolution: "http-cache-semantics@npm:4.2.0" @@ -9408,6 +9812,13 @@ __metadata: languageName: node linkType: hard +"human-signals@npm:^2.1.0": + version: 2.1.0 + resolution: "human-signals@npm:2.1.0" + checksum: 10/df59be9e0af479036798a881d1f136c4a29e0b518d4abb863afbd11bf30efa3eeb1d0425fc65942dcc05ab3bf40205ea436b0ff389f2cd20b75b8643d539bf86 + languageName: node + linkType: hard + "iconv-lite@npm:^0.7.2": version: 0.7.2 resolution: "iconv-lite@npm:0.7.2" @@ -9459,6 +9870,18 @@ __metadata: languageName: node linkType: hard +"import-local@npm:^3.0.2": + version: 3.2.0 + resolution: "import-local@npm:3.2.0" + dependencies: + pkg-dir: "npm:^4.2.0" + resolve-cwd: "npm:^3.0.0" + bin: + import-local-fixture: fixtures/cli.js + checksum: 10/0b0b0b412b2521739fbb85eeed834a3c34de9bc67e670b3d0b86248fc460d990a7b116ad056c084b87a693ef73d1f17268d6a5be626bb43c998a8b1c8a230004 + languageName: node + linkType: hard + "import-meta-resolve@npm:^4.1.0": version: 4.2.0 resolution: "import-meta-resolve@npm:4.2.0" @@ -9691,6 +10114,13 @@ __metadata: languageName: node linkType: hard +"is-generator-fn@npm:^2.0.0": + version: 2.1.0 + resolution: "is-generator-fn@npm:2.1.0" + checksum: 10/a6ad5492cf9d1746f73b6744e0c43c0020510b59d56ddcb78a91cbc173f09b5e6beff53d75c9c5a29feb618bfef2bf458e025ecf3a57ad2268e2fb2569f56215 + languageName: node + linkType: hard + "is-generator-function@npm:^1.0.10": version: 1.1.2 resolution: "is-generator-function@npm:1.1.2" @@ -9945,7 +10375,7 @@ __metadata: languageName: node linkType: hard -"istanbul-lib-coverage@npm:^3.2.0": +"istanbul-lib-coverage@npm:^3.0.0, istanbul-lib-coverage@npm:^3.2.0": version: 3.2.2 resolution: "istanbul-lib-coverage@npm:3.2.2" checksum: 10/40bbdd1e937dfd8c830fa286d0f665e81b7a78bdabcd4565f6d5667c99828bda3db7fb7ac6b96a3e2e8a2461ddbc5452d9f8bc7d00cb00075fa6a3e99f5b6a81 @@ -9965,22 +10395,67 @@ __metadata: languageName: node linkType: hard -"iterator.prototype@npm:^1.1.5": - version: 1.1.5 - resolution: "iterator.prototype@npm:1.1.5" +"istanbul-lib-instrument@npm:^6.0.0": + version: 6.0.3 + resolution: "istanbul-lib-instrument@npm:6.0.3" dependencies: - define-data-property: "npm:^1.1.4" - es-object-atoms: "npm:^1.0.0" - get-intrinsic: "npm:^1.2.6" - get-proto: "npm:^1.0.0" - has-symbols: "npm:^1.1.0" - set-function-name: "npm:^2.0.2" - checksum: 10/352bcf333f42189e65cc8cb2dcb94a5c47cf0a9110ce12aba788d405a980b5f5f3a06c79bf915377e1d480647169babd842ded0d898bed181bf6686e8e6823f6 + "@babel/core": "npm:^7.23.9" + "@babel/parser": "npm:^7.23.9" + "@istanbuljs/schema": "npm:^0.1.3" + istanbul-lib-coverage: "npm:^3.2.0" + semver: "npm:^7.5.4" + checksum: 10/aa5271c0008dfa71b6ecc9ba1e801bf77b49dc05524e8c30d58aaf5b9505e0cd12f25f93165464d4266a518c5c75284ecb598fbd89fec081ae77d2c9d3327695 languageName: node linkType: hard -"jackspeak@npm:^3.1.2": - version: 3.4.3 +"istanbul-lib-report@npm:^3.0.0": + version: 3.0.1 + resolution: "istanbul-lib-report@npm:3.0.1" + dependencies: + istanbul-lib-coverage: "npm:^3.0.0" + make-dir: "npm:^4.0.0" + supports-color: "npm:^7.1.0" + checksum: 10/86a83421ca1cf2109a9f6d193c06c31ef04a45e72a74579b11060b1e7bb9b6337a4e6f04abfb8857e2d569c271273c65e855ee429376a0d7c91ad91db42accd1 + languageName: node + linkType: hard + +"istanbul-lib-source-maps@npm:^4.0.0": + version: 4.0.1 + resolution: "istanbul-lib-source-maps@npm:4.0.1" + dependencies: + debug: "npm:^4.1.1" + istanbul-lib-coverage: "npm:^3.0.0" + source-map: "npm:^0.6.1" + checksum: 10/5526983462799aced011d776af166e350191b816821ea7bcf71cab3e5272657b062c47dc30697a22a43656e3ced78893a42de677f9ccf276a28c913190953b82 + languageName: node + linkType: hard + +"istanbul-reports@npm:^3.1.3": + version: 3.2.0 + resolution: "istanbul-reports@npm:3.2.0" + dependencies: + html-escaper: "npm:^2.0.0" + istanbul-lib-report: "npm:^3.0.0" + checksum: 10/6773a1d5c7d47eeec75b317144fe2a3b1da84a44b6282bebdc856e09667865e58c9b025b75b3d87f5bc62939126cbba4c871ee84254537d934ba5da5d4c4ec4e + languageName: node + linkType: hard + +"iterator.prototype@npm:^1.1.5": + version: 1.1.5 + resolution: "iterator.prototype@npm:1.1.5" + dependencies: + define-data-property: "npm:^1.1.4" + es-object-atoms: "npm:^1.0.0" + get-intrinsic: "npm:^1.2.6" + get-proto: "npm:^1.0.0" + has-symbols: "npm:^1.1.0" + set-function-name: "npm:^2.0.2" + checksum: 10/352bcf333f42189e65cc8cb2dcb94a5c47cf0a9110ce12aba788d405a980b5f5f3a06c79bf915377e1d480647169babd842ded0d898bed181bf6686e8e6823f6 + languageName: node + linkType: hard + +"jackspeak@npm:^3.1.2": + version: 3.4.3 resolution: "jackspeak@npm:3.4.3" dependencies: "@isaacs/cliui": "npm:^8.0.2" @@ -9992,6 +10467,155 @@ __metadata: languageName: node linkType: hard +"jest-changed-files@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-changed-files@npm:29.7.0" + dependencies: + execa: "npm:^5.0.0" + jest-util: "npm:^29.7.0" + p-limit: "npm:^3.1.0" + checksum: 10/3d93742e56b1a73a145d55b66e96711fbf87ef89b96c2fab7cfdfba8ec06612591a982111ca2b712bb853dbc16831ec8b43585a2a96b83862d6767de59cbf83d + languageName: node + linkType: hard + +"jest-circus@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-circus@npm:29.7.0" + dependencies: + "@jest/environment": "npm:^29.7.0" + "@jest/expect": "npm:^29.7.0" + "@jest/test-result": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" + "@types/node": "npm:*" + chalk: "npm:^4.0.0" + co: "npm:^4.6.0" + dedent: "npm:^1.0.0" + is-generator-fn: "npm:^2.0.0" + jest-each: "npm:^29.7.0" + jest-matcher-utils: "npm:^29.7.0" + jest-message-util: "npm:^29.7.0" + jest-runtime: "npm:^29.7.0" + jest-snapshot: "npm:^29.7.0" + jest-util: "npm:^29.7.0" + p-limit: "npm:^3.1.0" + pretty-format: "npm:^29.7.0" + pure-rand: "npm:^6.0.0" + slash: "npm:^3.0.0" + stack-utils: "npm:^2.0.3" + checksum: 10/716a8e3f40572fd0213bcfc1da90274bf30d856e5133af58089a6ce45089b63f4d679bd44e6be9d320e8390483ebc3ae9921981993986d21639d9019b523123d + languageName: node + linkType: hard + +"jest-cli@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-cli@npm:29.7.0" + dependencies: + "@jest/core": "npm:^29.7.0" + "@jest/test-result": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" + chalk: "npm:^4.0.0" + create-jest: "npm:^29.7.0" + exit: "npm:^0.1.2" + import-local: "npm:^3.0.2" + jest-config: "npm:^29.7.0" + jest-util: "npm:^29.7.0" + jest-validate: "npm:^29.7.0" + yargs: "npm:^17.3.1" + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + bin: + jest: bin/jest.js + checksum: 10/6cc62b34d002c034203065a31e5e9a19e7c76d9e8ef447a6f70f759c0714cb212c6245f75e270ba458620f9c7b26063cd8cf6cd1f7e3afd659a7cc08add17307 + languageName: node + linkType: hard + +"jest-config@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-config@npm:29.7.0" + dependencies: + "@babel/core": "npm:^7.11.6" + "@jest/test-sequencer": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" + babel-jest: "npm:^29.7.0" + chalk: "npm:^4.0.0" + ci-info: "npm:^3.2.0" + deepmerge: "npm:^4.2.2" + glob: "npm:^7.1.3" + graceful-fs: "npm:^4.2.9" + jest-circus: "npm:^29.7.0" + jest-environment-node: "npm:^29.7.0" + jest-get-type: "npm:^29.6.3" + jest-regex-util: "npm:^29.6.3" + jest-resolve: "npm:^29.7.0" + jest-runner: "npm:^29.7.0" + jest-util: "npm:^29.7.0" + jest-validate: "npm:^29.7.0" + micromatch: "npm:^4.0.4" + parse-json: "npm:^5.2.0" + pretty-format: "npm:^29.7.0" + slash: "npm:^3.0.0" + strip-json-comments: "npm:^3.1.1" + peerDependencies: + "@types/node": "*" + ts-node: ">=9.0.0" + peerDependenciesMeta: + "@types/node": + optional: true + ts-node: + optional: true + checksum: 10/6bdf570e9592e7d7dd5124fc0e21f5fe92bd15033513632431b211797e3ab57eaa312f83cc6481b3094b72324e369e876f163579d60016677c117ec4853cf02b + languageName: node + linkType: hard + +"jest-diff@npm:30.4.1": + version: 30.4.1 + resolution: "jest-diff@npm:30.4.1" + dependencies: + "@jest/diff-sequences": "npm:30.4.0" + "@jest/get-type": "npm:30.1.0" + chalk: "npm:^4.1.2" + pretty-format: "npm:30.4.1" + checksum: 10/594212df96bf101170afdb7eebd188d6d7d27241cbdd18b61d95f1142a3c94ae3b270377d15e719fb3c5efe4458d32acba8ad13dd6230dd7d6917a9eebb32625 + languageName: node + linkType: hard + +"jest-diff@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-diff@npm:29.7.0" + dependencies: + chalk: "npm:^4.0.0" + diff-sequences: "npm:^29.6.3" + jest-get-type: "npm:^29.6.3" + pretty-format: "npm:^29.7.0" + checksum: 10/6f3a7eb9cd9de5ea9e5aa94aed535631fa6f80221832952839b3cb59dd419b91c20b73887deb0b62230d06d02d6b6cf34ebb810b88d904bb4fe1e2e4f0905c98 + languageName: node + linkType: hard + +"jest-docblock@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-docblock@npm:29.7.0" + dependencies: + detect-newline: "npm:^3.0.0" + checksum: 10/8d48818055bc96c9e4ec2e217a5a375623c0d0bfae8d22c26e011074940c202aa2534a3362294c81d981046885c05d304376afba9f2874143025981148f3e96d + languageName: node + linkType: hard + +"jest-each@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-each@npm:29.7.0" + dependencies: + "@jest/types": "npm:^29.6.3" + chalk: "npm:^4.0.0" + jest-get-type: "npm:^29.6.3" + jest-util: "npm:^29.7.0" + pretty-format: "npm:^29.7.0" + checksum: 10/bd1a077654bdaa013b590deb5f7e7ade68f2e3289180a8c8f53bc8a49f3b40740c0ec2d3a3c1aee906f682775be2bebbac37491d80b634d15276b0aa0f2e3fda + languageName: node + linkType: hard + "jest-environment-node@npm:^29.7.0": version: 29.7.0 resolution: "jest-environment-node@npm:29.7.0" @@ -10036,6 +10660,40 @@ __metadata: languageName: node linkType: hard +"jest-leak-detector@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-leak-detector@npm:29.7.0" + dependencies: + jest-get-type: "npm:^29.6.3" + pretty-format: "npm:^29.7.0" + checksum: 10/e3950e3ddd71e1d0c22924c51a300a1c2db6cf69ec1e51f95ccf424bcc070f78664813bef7aed4b16b96dfbdeea53fe358f8aeaaea84346ae15c3735758f1605 + languageName: node + linkType: hard + +"jest-matcher-utils@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-matcher-utils@npm:29.7.0" + dependencies: + chalk: "npm:^4.0.0" + jest-diff: "npm:^29.7.0" + jest-get-type: "npm:^29.6.3" + pretty-format: "npm:^29.7.0" + checksum: 10/981904a494299cf1e3baed352f8a3bd8b50a8c13a662c509b6a53c31461f94ea3bfeffa9d5efcfeb248e384e318c87de7e3baa6af0f79674e987482aa189af40 + languageName: node + linkType: hard + +"jest-matcher-utils@npm:^30.4.1": + version: 30.4.1 + resolution: "jest-matcher-utils@npm:30.4.1" + dependencies: + "@jest/get-type": "npm:30.1.0" + chalk: "npm:^4.1.2" + jest-diff: "npm:30.4.1" + pretty-format: "npm:30.4.1" + checksum: 10/4da6e5c7fe5903fae7394233ea4b892567fb027065670c03096d01be0b389f858055c5ade20d59e82fedec6f3287e6f1720de526cd9a9ad3495432320adb9194 + languageName: node + linkType: hard + "jest-message-util@npm:^29.7.0": version: 29.7.0 resolution: "jest-message-util@npm:29.7.0" @@ -10064,6 +10722,18 @@ __metadata: languageName: node linkType: hard +"jest-pnp-resolver@npm:^1.2.2": + version: 1.2.3 + resolution: "jest-pnp-resolver@npm:1.2.3" + peerDependencies: + jest-resolve: "*" + peerDependenciesMeta: + jest-resolve: + optional: true + checksum: 10/db1a8ab2cb97ca19c01b1cfa9a9c8c69a143fde833c14df1fab0766f411b1148ff0df878adea09007ac6a2085ec116ba9a996a6ad104b1e58c20adbf88eed9b2 + languageName: node + linkType: hard + "jest-regex-util@npm:^29.6.3": version: 29.6.3 resolution: "jest-regex-util@npm:29.6.3" @@ -10071,6 +10741,120 @@ __metadata: languageName: node linkType: hard +"jest-resolve-dependencies@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-resolve-dependencies@npm:29.7.0" + dependencies: + jest-regex-util: "npm:^29.6.3" + jest-snapshot: "npm:^29.7.0" + checksum: 10/1e206f94a660d81e977bcfb1baae6450cb4a81c92e06fad376cc5ea16b8e8c6ea78c383f39e95591a9eb7f925b6a1021086c38941aa7c1b8a6a813c2f6e93675 + languageName: node + linkType: hard + +"jest-resolve@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-resolve@npm:29.7.0" + dependencies: + chalk: "npm:^4.0.0" + graceful-fs: "npm:^4.2.9" + jest-haste-map: "npm:^29.7.0" + jest-pnp-resolver: "npm:^1.2.2" + jest-util: "npm:^29.7.0" + jest-validate: "npm:^29.7.0" + resolve: "npm:^1.20.0" + resolve.exports: "npm:^2.0.0" + slash: "npm:^3.0.0" + checksum: 10/faa466fd9bc69ea6c37a545a7c6e808e073c66f46ab7d3d8a6ef084f8708f201b85d5fe1799789578b8b47fa1de47b9ee47b414d1863bc117a49e032ba77b7c7 + languageName: node + linkType: hard + +"jest-runner@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-runner@npm:29.7.0" + dependencies: + "@jest/console": "npm:^29.7.0" + "@jest/environment": "npm:^29.7.0" + "@jest/test-result": "npm:^29.7.0" + "@jest/transform": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" + "@types/node": "npm:*" + chalk: "npm:^4.0.0" + emittery: "npm:^0.13.1" + graceful-fs: "npm:^4.2.9" + jest-docblock: "npm:^29.7.0" + jest-environment-node: "npm:^29.7.0" + jest-haste-map: "npm:^29.7.0" + jest-leak-detector: "npm:^29.7.0" + jest-message-util: "npm:^29.7.0" + jest-resolve: "npm:^29.7.0" + jest-runtime: "npm:^29.7.0" + jest-util: "npm:^29.7.0" + jest-watcher: "npm:^29.7.0" + jest-worker: "npm:^29.7.0" + p-limit: "npm:^3.1.0" + source-map-support: "npm:0.5.13" + checksum: 10/9d8748a494bd90f5c82acea99be9e99f21358263ce6feae44d3f1b0cd90991b5df5d18d607e73c07be95861ee86d1cbab2a3fc6ca4b21805f07ac29d47c1da1e + languageName: node + linkType: hard + +"jest-runtime@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-runtime@npm:29.7.0" + dependencies: + "@jest/environment": "npm:^29.7.0" + "@jest/fake-timers": "npm:^29.7.0" + "@jest/globals": "npm:^29.7.0" + "@jest/source-map": "npm:^29.6.3" + "@jest/test-result": "npm:^29.7.0" + "@jest/transform": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" + "@types/node": "npm:*" + chalk: "npm:^4.0.0" + cjs-module-lexer: "npm:^1.0.0" + collect-v8-coverage: "npm:^1.0.0" + glob: "npm:^7.1.3" + graceful-fs: "npm:^4.2.9" + jest-haste-map: "npm:^29.7.0" + jest-message-util: "npm:^29.7.0" + jest-mock: "npm:^29.7.0" + jest-regex-util: "npm:^29.6.3" + jest-resolve: "npm:^29.7.0" + jest-snapshot: "npm:^29.7.0" + jest-util: "npm:^29.7.0" + slash: "npm:^3.0.0" + strip-bom: "npm:^4.0.0" + checksum: 10/59eb58eb7e150e0834a2d0c0d94f2a0b963ae7182cfa6c63f2b49b9c6ef794e5193ef1634e01db41420c36a94cefc512cdd67a055cd3e6fa2f41eaf0f82f5a20 + languageName: node + linkType: hard + +"jest-snapshot@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-snapshot@npm:29.7.0" + dependencies: + "@babel/core": "npm:^7.11.6" + "@babel/generator": "npm:^7.7.2" + "@babel/plugin-syntax-jsx": "npm:^7.7.2" + "@babel/plugin-syntax-typescript": "npm:^7.7.2" + "@babel/types": "npm:^7.3.3" + "@jest/expect-utils": "npm:^29.7.0" + "@jest/transform": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" + babel-preset-current-node-syntax: "npm:^1.0.0" + chalk: "npm:^4.0.0" + expect: "npm:^29.7.0" + graceful-fs: "npm:^4.2.9" + jest-diff: "npm:^29.7.0" + jest-get-type: "npm:^29.6.3" + jest-matcher-utils: "npm:^29.7.0" + jest-message-util: "npm:^29.7.0" + jest-util: "npm:^29.7.0" + natural-compare: "npm:^1.4.0" + pretty-format: "npm:^29.7.0" + semver: "npm:^7.5.3" + checksum: 10/cb19a3948256de5f922d52f251821f99657339969bf86843bd26cf3332eae94883e8260e3d2fba46129a27c3971c1aa522490e460e16c7fad516e82d10bbf9f8 + languageName: node + linkType: hard + "jest-util@npm:^29.7.0": version: 29.7.0 resolution: "jest-util@npm:29.7.0" @@ -10099,6 +10883,22 @@ __metadata: languageName: node linkType: hard +"jest-watcher@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-watcher@npm:29.7.0" + dependencies: + "@jest/test-result": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" + "@types/node": "npm:*" + ansi-escapes: "npm:^4.2.1" + chalk: "npm:^4.0.0" + emittery: "npm:^0.13.1" + jest-util: "npm:^29.7.0" + string-length: "npm:^4.0.1" + checksum: 10/4f616e0345676631a7034b1d94971aaa719f0cd4a6041be2aa299be437ea047afd4fe05c48873b7963f5687a2f6c7cbf51244be8b14e313b97bfe32b1e127e55 + languageName: node + linkType: hard + "jest-worker@npm:^29.7.0": version: 29.7.0 resolution: "jest-worker@npm:29.7.0" @@ -10111,6 +10911,25 @@ __metadata: languageName: node linkType: hard +"jest@npm:^29.7.0": + version: 29.7.0 + resolution: "jest@npm:29.7.0" + dependencies: + "@jest/core": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" + import-local: "npm:^3.0.2" + jest-cli: "npm:^29.7.0" + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + bin: + jest: bin/jest.js + checksum: 10/97023d78446098c586faaa467fbf2c6b07ff06e2c85a19e3926adb5b0effe9ac60c4913ae03e2719f9c01ae8ffd8d92f6b262cedb9555ceeb5d19263d8c6362a + languageName: node + linkType: hard + "jimp-compact@npm:0.16.1": version: 0.16.1 resolution: "jimp-compact@npm:0.16.1" @@ -10546,6 +11365,15 @@ __metadata: languageName: node linkType: hard +"make-dir@npm:^4.0.0": + version: 4.0.0 + resolution: "make-dir@npm:4.0.0" + dependencies: + semver: "npm:^7.5.3" + checksum: 10/bf0731a2dd3aab4db6f3de1585cea0b746bb73eb5a02e3d8d72757e376e64e6ada190b1eddcde5b2f24a81b688a9897efd5018737d05e02e2a671dda9cff8a8a + languageName: node + linkType: hard + "make-fetch-happen@npm:^15.0.0": version: 15.0.4 resolution: "make-fetch-happen@npm:15.0.4" @@ -12042,7 +12870,7 @@ __metadata: languageName: node linkType: hard -"npm-run-path@npm:^4.0.0": +"npm-run-path@npm:^4.0.0, npm-run-path@npm:^4.0.1": version: 4.0.1 resolution: "npm-run-path@npm:4.0.1" dependencies: @@ -12215,7 +13043,7 @@ __metadata: languageName: node linkType: hard -"onetime@npm:^5.1.0": +"onetime@npm:^5.1.0, onetime@npm:^5.1.2": version: 5.1.2 resolution: "onetime@npm:5.1.2" dependencies: @@ -12282,7 +13110,7 @@ __metadata: languageName: node linkType: hard -"p-limit@npm:^3.0.2": +"p-limit@npm:^3.0.2, p-limit@npm:^3.1.0": version: 3.1.0 resolution: "p-limit@npm:3.1.0" dependencies: @@ -12536,6 +13364,15 @@ __metadata: languageName: node linkType: hard +"pkg-dir@npm:^4.2.0": + version: 4.2.0 + resolution: "pkg-dir@npm:4.2.0" + dependencies: + find-up: "npm:^4.0.0" + checksum: 10/9863e3f35132bf99ae1636d31ff1e1e3501251d480336edb1c211133c8d58906bed80f154a1d723652df1fda91e01c7442c2eeaf9dc83157c7ae89087e43c8d6 + languageName: node + linkType: hard + "plist@npm:^3.0.5": version: 3.1.0 resolution: "plist@npm:3.1.0" @@ -12617,7 +13454,19 @@ __metadata: languageName: node linkType: hard -"pretty-format@npm:^29.7.0": +"pretty-format@npm:30.4.1, pretty-format@npm:^30.4.1": + version: 30.4.1 + resolution: "pretty-format@npm:30.4.1" + dependencies: + "@jest/schemas": "npm:30.4.1" + ansi-styles: "npm:^5.2.0" + react-is-18: "npm:react-is@^18.3.1" + react-is-19: "npm:react-is@^19.2.5" + checksum: 10/60311ef47a646eeaec0432efe66290cb6f0d2eccb123a28ad4ab6d7e53087bc62db91cfd54c3cc00c89d6875aefb2bf6264381b6c9411ce6bff3d6aa8280abad + languageName: node + linkType: hard + +"pretty-format@npm:^29.0.0, pretty-format@npm:^29.7.0": version: 29.7.0 resolution: "pretty-format@npm:29.7.0" dependencies: @@ -12658,7 +13507,7 @@ __metadata: languageName: node linkType: hard -"prompts@npm:^2.3.2, prompts@npm:^2.4.2": +"prompts@npm:^2.0.1, prompts@npm:^2.3.2, prompts@npm:^2.4.2": version: 2.4.2 resolution: "prompts@npm:2.4.2" dependencies: @@ -12696,6 +13545,13 @@ __metadata: languageName: node linkType: hard +"pure-rand@npm:^6.0.0": + version: 6.1.0 + resolution: "pure-rand@npm:6.1.0" + checksum: 10/256aa4bcaf9297256f552914e03cbdb0039c8fe1db11fa1e6d3f80790e16e563eb0a859a1e61082a95e224fc0c608661839439f8ecc6a3db4e48d46d99216ee4 + languageName: node + linkType: hard + "query-string@npm:^7.1.3": version: 7.1.3 resolution: "query-string@npm:7.1.3" @@ -12757,6 +13613,20 @@ __metadata: languageName: node linkType: hard +"react-is-18@npm:react-is@^18.3.1, react-is@npm:^18.0.0": + version: 18.3.1 + resolution: "react-is@npm:18.3.1" + checksum: 10/d5f60c87d285af24b1e1e7eaeb123ec256c3c8bdea7061ab3932e3e14685708221bf234ec50b21e10dd07f008f1b966a2730a0ce4ff67905b3872ff2042aec22 + languageName: node + linkType: hard + +"react-is-19@npm:react-is@^19.2.5": + version: 19.2.8 + resolution: "react-is@npm:19.2.8" + checksum: 10/09d053ff36e0ba4d47164baf9eab1c7772791f0371aa92281f0e547e0f6a5a5c8a1eb939aadd1a08b3c565958dd63ee59c746b6ac0d5ccb14ad742ac0314ab15 + languageName: node + linkType: hard + "react-is@npm:^16.13.1, react-is@npm:^16.7.0": version: 16.13.1 resolution: "react-is@npm:16.13.1" @@ -12764,13 +13634,6 @@ __metadata: languageName: node linkType: hard -"react-is@npm:^18.0.0": - version: 18.3.1 - resolution: "react-is@npm:18.3.1" - checksum: 10/d5f60c87d285af24b1e1e7eaeb123ec256c3c8bdea7061ab3932e3e14685708221bf234ec50b21e10dd07f008f1b966a2730a0ce4ff67905b3872ff2042aec22 - languageName: node - linkType: hard - "react-is@npm:^19.1.0": version: 19.2.4 resolution: "react-is@npm:19.2.4" @@ -12954,13 +13817,17 @@ __metadata: "@babel/core": "npm:^7.25.1" "@react-native/babel-preset": "npm:0.83.6" "@react-native/metro-config": "npm:^0.86.0" + "@testing-library/react-native": "npm:^14.0.1" + "@types/jest": "npm:^29.5.14" "@types/react": "npm:^19.1.12" del-cli: "npm:^6.0.0" + jest: "npm:^29.7.0" react: "npm:19.2.0" react-native: "npm:0.83.6" react-native-blob-util: "npm:^0.24.0" react-native-builder-bob: "npm:^0.40.18" react-native-worklets: "npm:0.9.1" + test-renderer: "npm:^1.2.0" typescript: "npm:~5.9.2" peerDependencies: react: "*" @@ -13306,6 +14173,17 @@ __metadata: languageName: node linkType: hard +"react-reconciler@npm:~0.33.0": + version: 0.33.0 + resolution: "react-reconciler@npm:0.33.0" + dependencies: + scheduler: "npm:^0.27.0" + peerDependencies: + react: ^19.2.0 + checksum: 10/eb8ddb8b5cfe850e022379df5330fad1871ab75dd7bc3a4e4c4fef7aa1b951b4cd784263186a0e5b01313de91b389499f95fc616f96b7d9e5dd33c13bdacb014 + languageName: node + linkType: hard + "react-refresh@npm:^0.14.0, react-refresh@npm:^0.14.2": version: 0.14.2 resolution: "react-refresh@npm:0.14.2" @@ -13494,6 +14372,15 @@ __metadata: languageName: node linkType: hard +"resolve-cwd@npm:^3.0.0": + version: 3.0.0 + resolution: "resolve-cwd@npm:3.0.0" + dependencies: + resolve-from: "npm:^5.0.0" + checksum: 10/546e0816012d65778e580ad62b29e975a642989108d9a3c5beabfb2304192fa3c9f9146fbdfe213563c6ff51975ae41bac1d3c6e047dd9572c94863a057b4d81 + languageName: node + linkType: hard + "resolve-from@npm:^4.0.0": version: 4.0.0 resolution: "resolve-from@npm:4.0.0" @@ -13515,6 +14402,27 @@ __metadata: languageName: node linkType: hard +"resolve.exports@npm:^2.0.0": + version: 2.0.3 + resolution: "resolve.exports@npm:2.0.3" + checksum: 10/536efee0f30a10fac8604e6cdc7844dbc3f4313568d09f06db4f7ed8a5b8aeb8585966fe975083d1f2dfbc87cf5f8bc7ab65a5c23385c14acbb535ca79f8398a + languageName: node + linkType: hard + +"resolve@npm:^1.20.0": + version: 1.22.12 + resolution: "resolve@npm:1.22.12" + dependencies: + es-errors: "npm:^1.3.0" + is-core-module: "npm:^2.16.1" + path-parse: "npm:^1.0.7" + supports-preserve-symlinks-flag: "npm:^1.0.0" + bin: + resolve: bin/resolve + checksum: 10/1d2a081e4b7198e2a70abd7bbbf8aea5380c2d074b6c870035aab50ebfb7312b6492b3588e752faef83a75147862a3d3e09b222bc9afd536804181fd3a515ef9 + languageName: node + linkType: hard + "resolve@npm:^1.22.11": version: 1.22.11 resolution: "resolve@npm:1.22.11" @@ -13544,6 +14452,20 @@ __metadata: languageName: node linkType: hard +"resolve@patch:resolve@npm%3A^1.20.0#optional!builtin": + version: 1.22.12 + resolution: "resolve@patch:resolve@npm%3A1.22.12#optional!builtin::version=1.22.12&hash=c3c19d" + dependencies: + es-errors: "npm:^1.3.0" + is-core-module: "npm:^2.16.1" + path-parse: "npm:^1.0.7" + supports-preserve-symlinks-flag: "npm:^1.0.0" + bin: + resolve: bin/resolve + checksum: 10/f80ad2c2b6820331cbe079198a184ffce322cfeca140065118066276bc08b03d5fa2c1ce652aeb584ec74050d1f656f46f034cc0dd9300452c5ab7866907f8c0 + languageName: node + linkType: hard + "resolve@patch:resolve@npm%3A^1.22.11#optional!builtin": version: 1.22.11 resolution: "resolve@patch:resolve@npm%3A1.22.11#optional!builtin::version=1.22.11&hash=c3c19d" @@ -13686,7 +14608,7 @@ __metadata: languageName: node linkType: hard -"scheduler@npm:0.27.0": +"scheduler@npm:0.27.0, scheduler@npm:^0.27.0": version: 0.27.0 resolution: "scheduler@npm:0.27.0" checksum: 10/eab3c3a8373195173e59c147224fc30dabe6dd453f248f5e610e8458512a5a2ee3a06465dc400ebfe6d35c9f5b7f3bb6b2e41c88c86fd177c25a73e7286a1e06 @@ -13912,7 +14834,7 @@ __metadata: languageName: node linkType: hard -"signal-exit@npm:^3.0.2, signal-exit@npm:^3.0.7": +"signal-exit@npm:^3.0.2, signal-exit@npm:^3.0.3, signal-exit@npm:^3.0.7": version: 3.0.7 resolution: "signal-exit@npm:3.0.7" checksum: 10/a2f098f247adc367dffc27845853e9959b9e88b01cb301658cfe4194352d8d2bb32e18467c786a7fe15f1d44b233ea35633d076d5e737870b7139949d1ab6318 @@ -14026,6 +14948,16 @@ __metadata: languageName: node linkType: hard +"source-map-support@npm:0.5.13": + version: 0.5.13 + resolution: "source-map-support@npm:0.5.13" + dependencies: + buffer-from: "npm:^1.0.0" + source-map: "npm:^0.6.0" + checksum: 10/d1514a922ac9c7e4786037eeff6c3322f461cd25da34bb9fefb15387b3490531774e6e31d95ab6d5b84a3e139af9c3a570ccaee6b47bd7ea262691ed3a8bc34e + languageName: node + linkType: hard + "source-map-support@npm:~0.5.20": version: 0.5.21 resolution: "source-map-support@npm:0.5.21" @@ -14207,6 +15139,16 @@ __metadata: languageName: node linkType: hard +"string-length@npm:^4.0.1": + version: 4.0.2 + resolution: "string-length@npm:4.0.2" + dependencies: + char-regex: "npm:^1.0.2" + strip-ansi: "npm:^6.0.0" + checksum: 10/ce85533ef5113fcb7e522bcf9e62cb33871aa99b3729cec5595f4447f660b0cefd542ca6df4150c97a677d58b0cb727a3fe09ac1de94071d05526c73579bf505 + languageName: node + linkType: hard + "string-natural-compare@npm:^3.0.1": version: 3.0.1 resolution: "string-natural-compare@npm:3.0.1" @@ -14332,6 +15274,13 @@ __metadata: languageName: node linkType: hard +"strip-bom@npm:^4.0.0": + version: 4.0.0 + resolution: "strip-bom@npm:4.0.0" + checksum: 10/9dbcfbaf503c57c06af15fe2c8176fb1bf3af5ff65003851a102749f875a6dbe0ab3b30115eccf6e805e9d756830d3e40ec508b62b3f1ddf3761a20ebe29d3f3 + languageName: node + linkType: hard + "strip-final-newline@npm:^2.0.0": version: 2.0.0 resolution: "strip-final-newline@npm:2.0.0" @@ -14487,6 +15436,18 @@ __metadata: languageName: node linkType: hard +"test-renderer@npm:^1.2.0": + version: 1.2.0 + resolution: "test-renderer@npm:1.2.0" + dependencies: + "@types/react-reconciler": "npm:~0.33.0" + react-reconciler: "npm:~0.33.0" + peerDependencies: + react: ^19.0.0 + checksum: 10/65be16c35ce1e0e2cef6b6f28043de96954077c303016fad98feee5d8fafd81ca62c215d566780e99b0f80064c40a92364122fbc96ec672e647f3f40fc365830 + languageName: node + linkType: hard + "text-table@npm:^0.2.0": version: 0.2.0 resolution: "text-table@npm:0.2.0" @@ -14923,6 +15884,17 @@ __metadata: languageName: node linkType: hard +"v8-to-istanbul@npm:^9.0.1": + version: 9.3.0 + resolution: "v8-to-istanbul@npm:9.3.0" + dependencies: + "@jridgewell/trace-mapping": "npm:^0.3.12" + "@types/istanbul-lib-coverage": "npm:^2.0.1" + convert-source-map: "npm:^2.0.0" + checksum: 10/fb1d70f1176cb9dc46cabbb3fd5c52c8f3e8738b61877b6e7266029aed0870b04140e3f9f4550ac32aebcfe1d0f38b0bac57e1e8fb97d68fec82f2b416148166 + languageName: node + linkType: hard + "validate-npm-package-name@npm:^5.0.0": version: 5.0.1 resolution: "validate-npm-package-name@npm:5.0.1" @@ -15271,6 +16243,21 @@ __metadata: languageName: node linkType: hard +"yargs@npm:^17.3.1": + version: 17.7.3 + resolution: "yargs@npm:17.7.3" + dependencies: + cliui: "npm:^8.0.1" + escalade: "npm:^3.1.1" + get-caller-file: "npm:^2.0.5" + require-directory: "npm:^2.1.1" + string-width: "npm:^4.2.3" + y18n: "npm:^5.0.5" + yargs-parser: "npm:^21.1.1" + checksum: 10/a3826798c03b159e139d0580a3b2733953889a9a1bac8e4e1ca7a1a249b55315b213c323a6a1dbdb305f6e59496a9eaa810742c87e34abcf1a0584d8f59212a1 + languageName: node + linkType: hard + "yargs@npm:^17.5.1, yargs@npm:^17.6.2": version: 17.7.2 resolution: "yargs@npm:17.7.2" From 0fb8765469f0244338253fbc8df86ef2a09e2ef2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20S=C5=82uszniak?= Date: Fri, 7 Aug 2026 16:21:11 +0200 Subject: [PATCH 2/7] test(ts): cover the core primitives, the fetcher and the pure helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `core/`: tensor byte semantics and copy windows, model execution and disposal, `wrapAsync` error propagation, and the spec matcher in depth — symbol binding, variant selection, runtime-constraint matching, and the authoring errors that fire before matching starts. - `fetcher/`: caching, forced re-download, byte-weighted progress, HTTP and transport failures, cancellation, requests shared between concurrent callers, iOS partial-file resume, and the Android DownloadManager backend. Telemetry gets its own suite, including the locale parsing that must not read a language-only tag as a country. - `extensions/`: box and point scaling under both resize modes, box decoding, and the seeded generators. --- .../__tests__/core/model.test.ts | 77 ++++ .../__tests__/core/runtime.test.ts | 38 ++ .../__tests__/core/schema.test.ts | 436 ++++++++++++++++++ .../__tests__/core/tensor.test.ts | 142 ++++++ .../__tests__/extensions/ops.test.ts | 238 ++++++++++ .../__tests__/fetcher/androidBackend.test.ts | 110 +++++ .../__tests__/fetcher/download.test.ts | 362 +++++++++++++++ .../__tests__/fetcher/telemetry.test.ts | 153 ++++++ 8 files changed, 1556 insertions(+) create mode 100644 packages/react-native-executorch/__tests__/core/model.test.ts create mode 100644 packages/react-native-executorch/__tests__/core/runtime.test.ts create mode 100644 packages/react-native-executorch/__tests__/core/schema.test.ts create mode 100644 packages/react-native-executorch/__tests__/core/tensor.test.ts create mode 100644 packages/react-native-executorch/__tests__/extensions/ops.test.ts create mode 100644 packages/react-native-executorch/__tests__/fetcher/androidBackend.test.ts create mode 100644 packages/react-native-executorch/__tests__/fetcher/download.test.ts create mode 100644 packages/react-native-executorch/__tests__/fetcher/telemetry.test.ts diff --git a/packages/react-native-executorch/__tests__/core/model.test.ts b/packages/react-native-executorch/__tests__/core/model.test.ts new file mode 100644 index 0000000000..2fad159649 --- /dev/null +++ b/packages/react-native-executorch/__tests__/core/model.test.ts @@ -0,0 +1,77 @@ +import { loadModel } from '../../src/core/model'; +import { tensor } from '../../src/core/tensor'; +import { f32, i64, method } from '../../src/core/schema'; +import { fakeJsi } from '../support/fakeJsi'; +import { exported, writesOutputs } from '../support/fixtures'; + +const PATH = '/models/fixture.pte'; + +describe('loadModel', () => { + it('exposes the path, exported schema and per-method backends', () => { + const schema = exported({ + ...method('forward', [f32(1, 3, 8, 8)], [f32(1, 4)]), + ...method('encode', [i64(1, 16)], [f32(1, 16, 32)]), + }); + fakeJsi.registerModel(PATH, { + schema, + backends: { forward: ['XnnpackBackend'], encode: ['CoreMLBackend'] }, + }); + + const model = loadModel(PATH); + + expect(model.path).toBe(PATH); + expect(Object.keys(model.schema).sort()).toEqual(['encode', 'forward']); + expect(model.backends).toEqual({ + forward: ['XnnpackBackend'], + encode: ['CoreMLBackend'], + }); + + model.dispose(); + }); + + it('propagates a load failure as a thrown error', () => { + expect(() => loadModel('/models/missing.pte')).toThrow(/missing.pte/); + }); +}); + +describe('Model.execute', () => { + beforeEach(() => { + fakeJsi.registerModel(PATH, { + schema: exported(method('forward', [f32(4)], [f32(4)])), + execute: writesOutputs([10, 20, 30, 40]), + }); + }); + + it('writes into the pre-allocated output tensors and returns them', () => { + const model = loadModel(PATH); + const input = tensor('float32', [4]); + const output = tensor('float32', [4]); + + const returned = model.execute('forward', [input], [output]); + + expect(returned).toEqual([output]); + expect([...output.getData(new Float32Array(4))]).toEqual([10, 20, 30, 40]); + + input.dispose(); + output.dispose(); + model.dispose(); + }); + + it('rejects a method the model does not export', () => { + const model = loadModel(PATH); + expect(() => model.execute('decode', [], [])).toThrow(/'decode'/); + model.dispose(); + }); + + it('rejects use after dispose rather than reading freed memory', () => { + const model = loadModel(PATH); + model.dispose(); + expect(() => model.execute('forward', [], [])).toThrow(/disposed/); + }); + + it('is idempotent on dispose', () => { + const model = loadModel(PATH); + model.dispose(); + expect(() => model.dispose()).not.toThrow(); + }); +}); diff --git a/packages/react-native-executorch/__tests__/core/runtime.test.ts b/packages/react-native-executorch/__tests__/core/runtime.test.ts new file mode 100644 index 0000000000..e9fe69e7f2 --- /dev/null +++ b/packages/react-native-executorch/__tests__/core/runtime.test.ts @@ -0,0 +1,38 @@ +import { defaultWorkletRuntime, wrapAsync } from '../../src/core/runtime'; + +describe('wrapAsync', () => { + it('forwards every argument and resolves with the return value', async () => { + const fn = jest.fn((a: number, b: string) => `${a}-${b}`); + await expect(wrapAsync(fn)(7, 'x')).resolves.toBe('7-x'); + expect(fn).toHaveBeenCalledWith(7, 'x'); + }); + + it('returns a promise even for a synchronous function', () => { + expect(wrapAsync(() => 1)()).toBeInstanceOf(Promise); + }); + + it('rejects with an Error carrying the original message', async () => { + const boom = () => { + throw new Error('model failed to load'); + }; + await expect(wrapAsync(boom)()).rejects.toThrow('model failed to load'); + }); + + it('rejects with an Error even when a non-Error was thrown', async () => { + const boom = () => { + throw 'plain string failure'; + }; + const rejection = await wrapAsync(boom)().catch((e) => e); + expect(rejection).toBeInstanceOf(Error); + expect(rejection.message).toBe('plain string failure'); + }); + + it('runs on the default runtime when none is given', () => { + expect(defaultWorkletRuntime).toBeDefined(); + }); + + it('accepts an explicit runtime', async () => { + const runtime = { name: 'custom' } as never; + await expect(wrapAsync(() => 'ok', runtime)()).resolves.toBe('ok'); + }); +}); diff --git a/packages/react-native-executorch/__tests__/core/schema.test.ts b/packages/react-native-executorch/__tests__/core/schema.test.ts new file mode 100644 index 0000000000..24036a52ba --- /dev/null +++ b/packages/react-native-executorch/__tests__/core/schema.test.ts @@ -0,0 +1,436 @@ +import { + ConstantDim, + DynamicDim, + EnumDim, + RangeDim, + StaticDim, + constr, + f32, + i64, + method, + validateSpec, +} from '../../src/core/schema'; +import { exported } from '../support/fixtures'; + +// A dimension reference helper, since constraints are written by hand here. +const inputDim = (tensorIdx: number, dimIdx: number) => + ({ paramSide: 'input', tensorIdx, dimIdx }) as const; +const outputDim = (tensorIdx: number, dimIdx: number) => + ({ paramSide: 'output', tensorIdx, dimIdx }) as const; + +describe('dimension constructors', () => { + it.each([0, -1, 1.5, NaN])('ConstantDim rejects %p', (value) => { + expect(() => ConstantDim(value)).toThrow(/positive integer/); + }); + + it.each([0, -1, 2.5])('EnumDim rejects the choice %p', (value) => { + expect(() => EnumDim([1, value])).toThrow(/positive integer/); + }); + + it('RangeDim rejects a non-positive minimum', () => { + expect(() => RangeDim(0, 10)).toThrow(/range min/); + }); + + it('RangeDim rejects a maximum below the minimum', () => { + expect(() => RangeDim(10, 5)).toThrow(/max cannot be less than min/); + }); + + it('RangeDim rejects a non-positive step', () => { + expect(() => RangeDim(1, 10, 0)).toThrow(/range step/); + }); + + it('RangeDim defaults the step to 1', () => { + expect(RangeDim(1, 10)).toEqual({ kind: 'range', range: { min: 1, max: 10, step: 1 } }); + }); + + it('accepts a degenerate range where max equals min', () => { + expect(RangeDim(4, 4)).toEqual({ kind: 'range', range: { min: 4, max: 4, step: 1 } }); + }); +}); + +describe('SymbolicTensor shorthand', () => { + it('turns numbers into constants and strings into static symbols', () => { + expect(f32(1, 'H')).toEqual({ + kind: 'Tensor', + dtype: 'float32', + shape: [ + { kind: 'constant', value: 1 }, + { kind: 'static', symbol: 'H' }, + ], + }); + }); + + it('passes explicit dimension objects through unchanged', () => { + const dim = RangeDim(1, 8); + expect(f32(dim).shape[0]).toBe(dim); + }); +}); + +describe('validateSpec — matching', () => { + it('binds static symbols to the exported constants', () => { + const match = validateSpec(exported(method('forward', [f32(1, 3, 224, 224)], [f32(1, 1000)])), { + only: method('forward', [f32(1, 3, 'H', 'W')], [f32(1, 'N')]), + }); + + expect(match.variant).toBe('only'); + expect(match.dims.constant('N', 'H', 'W')).toEqual([1000, 224, 224]); + }); + + it('returns the first variant that matches, not the best one', () => { + const match = validateSpec(exported(method('forward', [f32(3, 8, 8)], [f32(4)])), { + batched: method('forward', [f32(1, 3, 'H', 'W')], [f32(1, 'N')]), + unbatched: method('forward', [f32(3, 'H', 'W')], [f32('N')]), + }); + + expect(match.variant).toBe('unbatched'); + }); + + it('reports every variant that failed, with its reason', () => { + expect(() => + validateSpec(exported(method('forward', [f32(3, 8, 8)], [f32(4, 4)])), { + batched: method('forward', [f32(1, 3, 'H', 'W')], [f32(1, 'N')]), + unbatched: method('forward', [f32(3, 'H', 'W')], [f32('N')]), + }) + ).toThrow(/Variant 'batched'[\s\S]*Variant 'unbatched'/); + }); + + it('rejects a dtype mismatch', () => { + expect(() => + validateSpec(exported(method('forward', [i64(4)], [f32(4)])), { + only: method('forward', [f32(4)], [f32(4)]), + }) + ).toThrow(/DType mismatch/); + }); + + it('rejects a rank mismatch', () => { + expect(() => + validateSpec(exported(method('forward', [f32(1, 4)], [f32(4)])), { + only: method('forward', [f32(4)], [f32(4)]), + }) + ).toThrow(/Rank mismatch/); + }); + + it('rejects a differing input count', () => { + expect(() => + validateSpec(exported(method('forward', [f32(4), f32(4)], [f32(4)])), { + only: method('forward', [f32(4)], [f32(4)]), + }) + ).toThrow(/Input count mismatch/); + }); + + it('rejects a differing output count', () => { + expect(() => + validateSpec(exported(method('forward', [f32(4)], [f32(4), f32(4)])), { + only: method('forward', [f32(4)], [f32(4)]), + }) + ).toThrow(/Output count mismatch/); + }); + + it('rejects a method the exported model does not have', () => { + expect(() => + validateSpec(exported(method('forward', [f32(4)], [f32(4)])), { + only: method('encode', [f32(4)], [f32(4)]), + }) + ).toThrow(/Method 'encode' not found/); + }); + + it('ignores exported methods the allowed spec does not mention', () => { + const spec = exported({ + ...method('forward', [f32(4)], [f32(4)]), + ...method('reset', [], []), + }); + + expect(validateSpec(spec, { only: method('forward', [f32(4)], [f32(4)]) }).variant).toBe( + 'only' + ); + }); + + it('rejects a primitive slot matched against a tensor slot', () => { + expect(() => + validateSpec( + exported({ forward: { inputs: [{ kind: 'Int' }], outputs: [], runtimeConstraints: [] } }), + { + only: method('forward', [f32(4)], []), + } + ) + ).toThrow(/kind mismatch/); + }); +}); + +describe('validateSpec — symbol binding', () => { + it('requires repeated static symbols to bind to the same value', () => { + expect(() => + validateSpec(exported(method('forward', [f32(8, 16)], [f32(4)])), { + only: method('forward', [f32('S', 'S')], [f32(4)]), + }) + ).toThrow(/inconsistent bindings/); + }); + + it('accepts repeated static symbols that agree', () => { + const match = validateSpec(exported(method('forward', [f32(8, 8)], [f32(8)])), { + only: method('forward', [f32('S', 'S')], [f32('S')]), + }); + expect(match.dim('S', 'constant')).toBe(8); + }); + + it('binds dynamic symbols to ranges', () => { + const match = validateSpec(exported(method('forward', [f32(RangeDim(1, 64, 2))], [f32(4)])), { + only: method('forward', [f32(DynamicDim('L'))], [f32(4)]), + }); + expect(match.dim('L', 'range')).toEqual({ min: 1, max: 64, step: 2 }); + }); + + it('binds dynamic symbols to enums', () => { + const match = validateSpec(exported(method('forward', [f32(EnumDim([2, 4, 8]))], [f32(4)])), { + only: method('forward', [f32(DynamicDim('L'))], [f32(4)]), + }); + expect([...match.dim('L', 'enum')]).toEqual([2, 4, 8]); + }); + + it('treats enum choices as a set, not a sequence', () => { + const match = validateSpec(exported(method('forward', [f32(EnumDim([8, 2, 4]))], [f32(4)])), { + only: method('forward', [f32(EnumDim([2, 4, 8]))], [f32(4)]), + }); + expect(match.variant).toBe('only'); + }); + + it('rejects a dynamic symbol bound to a constant', () => { + expect(() => + validateSpec(exported(method('forward', [f32(8)], [f32(4)])), { + only: method('forward', [f32(DynamicDim('L'))], [f32(4)]), + }) + ).toThrow(/Cannot match symbolic 'dynamic' with concrete 'constant'/); + }); + + it('rejects a static symbol bound to a range', () => { + expect(() => + validateSpec(exported(method('forward', [f32(RangeDim(1, 8))], [f32(4)])), { + only: method('forward', [f32(StaticDim('S'))], [f32(4)]), + }) + ).toThrow(/Cannot match symbolic 'static' with concrete 'range'/); + }); + + it('rejects a range whose bounds differ from the exported ones', () => { + expect(() => + validateSpec(exported(method('forward', [f32(RangeDim(1, 8))], [f32(4)])), { + only: method('forward', [f32(RangeDim(1, 16))], [f32(4)]), + }) + ).toThrow(/Range dimension mismatch/); + }); + + it('rejects a symbol used as both static and dynamic', () => { + expect(() => + validateSpec(exported(method('forward', [f32(8), f32(RangeDim(1, 8))], [f32(4)])), { + only: method('forward', [f32(StaticDim('S')), f32(DynamicDim('S'))], [f32(4)]), + }) + ).toThrow(/used as both 'static' and 'dynamic'/); + }); + + it('binds symbols across methods of the same spec', () => { + const spec = exported({ + ...method('encode', [f32(4)], [f32(1, 512)]), + ...method('decode', [f32(1, 512)], [f32(10)]), + }); + + const match = validateSpec(spec, { + only: { + ...method('encode', [f32(4)], [f32(1, 'D')]), + ...method('decode', [f32(1, 'D')], [f32(10)]), + }, + }); + expect(match.dim('D', 'constant')).toBe(512); + }); +}); + +describe('validateSpec — SpecMatch accessors', () => { + const spec = exported( + method('forward', [f32(2, RangeDim(1, 8)), f32(EnumDim([16, 32]))], [f32(4)]) + ); + const allowed = { + only: method('forward', [f32('B', DynamicDim('L')), f32(DynamicDim('E'))], [f32(4)]), + }; + + it('exposes the raw dim when no kind is requested', () => { + expect(validateSpec(spec, allowed).dim('B')).toEqual({ kind: 'constant', value: 2 }); + }); + + it('throws when a symbol is asked for as the wrong kind', () => { + expect(() => validateSpec(spec, allowed).dim('B', 'range')).toThrow( + /is 'constant', expected 'range'/ + ); + }); + + it("treats 'dynamic' as either range or enum", () => { + const match = validateSpec(spec, allowed); + expect(match.dim('L', 'dynamic').kind).toBe('range'); + expect(match.dim('E', 'dynamic').kind).toBe('enum'); + expect(() => match.dim('B', 'dynamic')).toThrow(/is 'constant', expected 'dynamic'/); + }); + + it('throws for a symbol that was never bound', () => { + expect(() => validateSpec(spec, allowed).dim('nope')).toThrow(/not found in bindings/); + }); + + it('returns batch accessors as tuples in the requested order', () => { + const match = validateSpec(spec, allowed); + expect(match.dims.constant('B')).toEqual([2]); + expect(match.dims.range('L')).toEqual([{ min: 1, max: 8, step: 1 }]); + expect(match.dims.enum('E').map((choices) => [...choices])).toEqual([[16, 32]]); + expect(match.dims.any('B', 'E')).toEqual([ + { kind: 'constant', value: 2 }, + { kind: 'enum', choices: [16, 32] }, + ]); + }); +}); + +describe('validateSpec — runtime constraints', () => { + const withConstraint = (constraints: Parameters[3]) => + exported( + method( + 'forward', + [i64(1, RangeDim(1, 128)), i64(1, RangeDim(1, 128))], + [f32(1, 384)], + constraints + ) + ); + + const equality = [constr.eq(inputDim(0, 1), inputDim(1, 1))]; + + const allowed = (constraints: Parameters[3]) => ({ + only: method( + 'forward', + [i64(1, DynamicDim('L')), i64(1, DynamicDim('L'))], + [f32(1, 'D')], + constraints + ), + }); + + it('accepts a spec declaring exactly the required constraints', () => { + expect(validateSpec(withConstraint(equality), allowed(equality)).variant).toBe('only'); + }); + + it('rejects a spec missing a required constraint', () => { + expect(() => validateSpec(withConstraint([]), allowed(equality))).toThrow( + /Not declared by the exported model spec/ + ); + }); + + it('rejects a spec declaring an extra constraint', () => { + expect(() => validateSpec(withConstraint(equality), allowed([]))).toThrow( + /unexpected runtime constraints/ + ); + }); + + it('matches equality constraints regardless of the order of their dimensions', () => { + const reversed = [constr.eq(inputDim(1, 1), inputDim(0, 1))]; + expect(validateSpec(withConstraint(reversed), allowed(equality)).variant).toBe('only'); + }); + + it('rejects an equality constraint over a different set of dimensions', () => { + const elsewhere = [constr.eq(inputDim(0, 1), outputDim(0, 1))]; + expect(() => validateSpec(withConstraint(elsewhere), allowed(equality))).toThrow( + /Not declared by the exported model spec/ + ); + }); + + it('matches linear constraints on their coefficients', () => { + const spec = exported( + method( + 'forward', + [f32(RangeDim(1, 64))], + [f32(RangeDim(1, 64))], + [constr.linear(outputDim(0, 0), inputDim(0, 0), 2, 1)] + ) + ); + + const same = { + only: method( + 'forward', + [f32(DynamicDim('L'))], + [f32(DynamicDim('L'))], + [constr.linear(outputDim(0, 0), inputDim(0, 0), 2, 1)] + ), + }; + const different = { + only: method( + 'forward', + [f32(DynamicDim('L'))], + [f32(DynamicDim('L'))], + [constr.linear(outputDim(0, 0), inputDim(0, 0), 2, 0)] + ), + }; + + expect(validateSpec(spec, same).variant).toBe('only'); + expect(() => validateSpec(spec, different)).toThrow(/Not declared/); + }); + + it('defaults the linear intercept to zero', () => { + expect(constr.linear(outputDim(0, 0), inputDim(0, 0), 2).coefficients).toEqual([2, 0]); + }); +}); + +describe('validateSpec — authoring errors', () => { + const anySpec = exported(method('forward', [f32(4)], [f32(4)])); + + it('rejects an equality constraint over fewer than two dimensions', () => { + expect(() => + validateSpec(anySpec, { + only: method('forward', [f32(4)], [f32(4)], [constr.eq(inputDim(0, 0))]), + }) + ).toThrow(/at least two dimensions/); + }); + + it('rejects non-integer linear coefficients', () => { + expect(() => + validateSpec(anySpec, { + only: method( + 'forward', + [f32(4)], + [f32(4)], + [constr.linear(outputDim(0, 0), inputDim(0, 0), 1.5)] + ), + }) + ).toThrow(/Coefficients must be integers/); + }); + + it('rejects a constraint referencing a tensor that does not exist', () => { + expect(() => + validateSpec(anySpec, { + only: method('forward', [f32(4)], [f32(4)], [constr.eq(inputDim(3, 0), outputDim(0, 0))]), + }) + ).toThrow(/tensor index out of range/); + }); + + it('rejects a constraint referencing a dimension that does not exist', () => { + expect(() => + validateSpec(anySpec, { + only: method('forward', [f32(4)], [f32(4)], [constr.eq(inputDim(0, 5), outputDim(0, 0))]), + }) + ).toThrow(/dimension index out of range/); + }); + + it('surfaces authoring errors before any variant is tried', () => { + // The second variant would match, but the first one is malformed — a bug + // in a pipeline's own spec must not be masked by a later variant. + expect(() => + validateSpec(anySpec, { + broken: method('forward', [f32(4)], [f32(4)], [constr.eq(inputDim(9, 0), outputDim(0, 0))]), + fine: method('forward', [f32(4)], [f32(4)]), + }) + ).toThrow(/tensor index out of range/); + }); + + it('rejects an exported spec carrying an invalid dimension domain', () => { + const invalid = { + forward: { + inputs: [{ kind: 'Tensor', dtype: 'float32', shape: [{ kind: 'enum', choices: [] }] }], + outputs: [], + runtimeConstraints: [], + }, + } as never; + + expect(() => validateSpec(invalid, { only: method('forward', [f32(4)], []) })).toThrow( + /enum must have at least one choice/ + ); + }); +}); diff --git a/packages/react-native-executorch/__tests__/core/tensor.test.ts b/packages/react-native-executorch/__tests__/core/tensor.test.ts new file mode 100644 index 0000000000..af17a1c88c --- /dev/null +++ b/packages/react-native-executorch/__tests__/core/tensor.test.ts @@ -0,0 +1,142 @@ +import { tensor } from '../../src/core/tensor'; + +describe('tensor()', () => { + it('allocates with the requested dtype and shape', () => { + const t = tensor('float32', [2, 3, 4]); + expect(t.dtype).toBe('float32'); + expect([...t.shape]).toEqual([2, 3, 4]); + expect(t.numel).toBe(24); + t.dispose(); + }); + + it('initializes from a typed array when one is passed', () => { + const t = tensor('float32', [2, 2], new Float32Array([1.5, -2.5, 3, 4.25])); + expect([...t.getData(new Float32Array(4))]).toEqual([1.5, -2.5, 3, 4.25]); + t.dispose(); + }); + + it.each([ + ['float32', Float32Array, [1.5, -2.5]], + ['int32', Int32Array, [7, -9]], + ['uint8', Uint8Array, [3, 250]], + ] as const)('round-trips %s data', (dtype, Ctor, values) => { + const t = tensor(dtype, [2], new Ctor(values as unknown as number[])); + expect([...t.getData(new Ctor(2))]).toEqual(values); + t.dispose(); + }); + + it('round-trips int64 data through a BigInt64Array', () => { + const t = tensor('int64', [2], BigInt64Array.from([5n, -7n])); + expect([...t.getData(new BigInt64Array(2))]).toEqual([5n, -7n]); + t.dispose(); + }); + + it('rejects a source whose byte size does not match', () => { + const t = tensor('float32', [4]); + expect(() => t.setData(new Float32Array(3))).toThrow(/bytes/); + t.dispose(); + }); + + it('rejects a destination whose byte size does not match', () => { + const t = tensor('float32', [4]); + expect(() => t.getData(new Float32Array(5))).toThrow(/bytes/); + t.dispose(); + }); +}); + +describe('Tensor.copyTo', () => { + it('copies the whole tensor by default and returns the destination', () => { + const src = tensor('float32', [4], new Float32Array([1, 2, 3, 4])); + const dst = tensor('float32', [4]); + + expect(src.copyTo(dst)).toBe(dst); + expect([...dst.getData(new Float32Array(4))]).toEqual([1, 2, 3, 4]); + + src.dispose(); + dst.dispose(); + }); + + it('copies only the requested window', () => { + const src = tensor('float32', [5], new Float32Array([1, 2, 3, 4, 5])); + const dst = tensor('float32', [2]); + + src.copyTo(dst, { offset: 1, length: 2 }); + expect([...dst.getData(new Float32Array(2))]).toEqual([2, 3]); + + src.dispose(); + dst.dispose(); + }); + + it('copies to the end of the source when only an offset is given', () => { + const src = tensor('float32', [4], new Float32Array([1, 2, 3, 4])); + const dst = tensor('float32', [4]); + + src.copyTo(dst, { offset: 2 }); + expect([...dst.getData(new Float32Array(4))].slice(0, 2)).toEqual([3, 4]); + + src.dispose(); + dst.dispose(); + }); + + it('rejects a window that runs past the end of the source', () => { + const src = tensor('float32', [4]); + const dst = tensor('float32', [4]); + + expect(() => src.copyTo(dst, { offset: 3, length: 2 })).toThrow(/out of bounds/); + + src.dispose(); + dst.dispose(); + }); + + it('flattens across ranks as long as the element count fits', () => { + const src = tensor('float32', [1, 2, 2], new Float32Array([1, 2, 3, 4])); + const dst = tensor('float32', [2, 2]); + + src.copyTo(dst); + expect([...dst.getData(new Float32Array(4))]).toEqual([1, 2, 3, 4]); + + src.dispose(); + dst.dispose(); + }); +}); + +describe('Tensor.through / throughIf', () => { + it('passes the tensor as the first argument and forwards the rest', () => { + const t = tensor('float32', [2]); + const spy = jest.fn((_self: unknown, a: number, b: string) => `${a}${b}`); + + expect(t.through(spy, 1, 'x')).toBe('1x'); + expect(spy).toHaveBeenCalledWith(t, 1, 'x'); + + t.dispose(); + }); + + it('applies the function only when the predicate holds', () => { + const t = tensor('float32', [2]); + const other = tensor('float32', [2]); + const fn = jest.fn(() => other); + + expect(t.throughIf(false, fn)).toBe(t); + expect(fn).not.toHaveBeenCalled(); + + expect(t.throughIf(true, fn)).toBe(other); + expect(fn).toHaveBeenCalledTimes(1); + + t.dispose(); + other.dispose(); + }); +}); + +describe('Tensor.dispose', () => { + it('makes further use an error rather than a silent read of freed memory', () => { + const t = tensor('float32', [2]); + t.dispose(); + expect(() => t.getData(new Float32Array(2))).toThrow(/disposed/); + }); + + it('is idempotent', () => { + const t = tensor('float32', [2]); + t.dispose(); + expect(() => t.dispose()).not.toThrow(); + }); +}); diff --git a/packages/react-native-executorch/__tests__/extensions/ops.test.ts b/packages/react-native-executorch/__tests__/extensions/ops.test.ts new file mode 100644 index 0000000000..2c33538141 --- /dev/null +++ b/packages/react-native-executorch/__tests__/extensions/ops.test.ts @@ -0,0 +1,238 @@ +/** + * The extension helpers that are pure TypeScript. + * + * Box and point scaling, box decoding and the seeded generators run entirely in + * JS — no native call, no model — so they are exactly as correct as this suite + * says they are. Coordinate transforms in particular fail quietly: an + * off-by-half in the letterbox offset shifts every detection by a few pixels, + * which looks plausible in a demo and wrong in production. + */ +import { mulberry32, randomNormal } from '../../src/extensions/math'; +import { decodeBox, scaleBox } from '../../src/extensions/cv/ops/boxes'; +import { scalePoint } from '../../src/extensions/cv/ops/points'; +import { FORMAT_CHANNELS, FORMAT_CONVERSION } from '../../src/extensions/cv/ops/image'; + +describe('decodeBox', () => { + it('reads an xyxy tuple as two corners', () => { + expect(decodeBox([1, 2, 3, 4], 'xyxy')).toEqual({ + format: 'xyxy', + xmin: 1, + ymin: 2, + xmax: 3, + ymax: 4, + }); + }); + + it('reads an xywh tuple as a corner plus a size', () => { + expect(decodeBox([1, 2, 3, 4], 'xywh')).toEqual({ + format: 'xywh', + xmin: 1, + ymin: 2, + w: 3, + h: 4, + }); + }); + + it('reads a cxcywh tuple as a centre plus a size', () => { + expect(decodeBox([1, 2, 3, 4], 'cxcywh')).toEqual({ + format: 'cxcywh', + cx: 1, + cy: 2, + w: 3, + h: 4, + }); + }); +}); + +describe('scalePoint', () => { + const from = { width: 100, height: 100 }; + + it('is the identity when the resolutions match', () => { + expect(scalePoint({ x: 10, y: 20 }, { from, to: from, resizeMode: 'stretch' })).toEqual({ + x: 10, + y: 20, + }); + }); + + it('scales each axis independently when stretching', () => { + const to = { width: 200, height: 50 }; + expect(scalePoint({ x: 10, y: 10 }, { from, to, resizeMode: 'stretch' })).toEqual({ + x: 20, + y: 5, + }); + }); + + it('removes the letterbox padding before scaling', () => { + // A 200x100 image into a 100x100 input: scale 0.5, 25px of padding top and + // bottom. The centre of the model input is the centre of the image. + const to = { width: 200, height: 100 }; + expect(scalePoint({ x: 50, y: 50 }, { from, to, resizeMode: 'letterbox' })).toEqual({ + x: 100, + y: 50, + }); + }); + + it('maps a point inside the letterbox padding to outside the image', () => { + const to = { width: 200, height: 100 }; + expect(scalePoint({ x: 0, y: 0 }, { from, to, resizeMode: 'letterbox' }).y).toBeLessThan(0); + }); + + it('round-trips the corners of a letterboxed image', () => { + const to = { width: 200, height: 100 }; + const opts = { from, to, resizeMode: 'letterbox' } as const; + + expect(scalePoint({ x: 0, y: 25 }, opts)).toEqual({ x: 0, y: 0 }); + expect(scalePoint({ x: 100, y: 75 }, opts)).toEqual({ x: 200, y: 100 }); + }); +}); + +describe('scaleBox', () => { + const from = { width: 100, height: 100 }; + const to = { width: 200, height: 100 }; + + it('scales both corners of an xyxy box when stretching', () => { + const box = decodeBox([10, 10, 20, 20], 'xyxy'); + expect(scaleBox(box, { from, to, resizeMode: 'stretch' })).toEqual({ + format: 'xyxy', + xmin: 20, + ymin: 10, + xmax: 40, + ymax: 20, + }); + }); + + it('scales the origin and the extent of an xywh box', () => { + const box = decodeBox([10, 10, 20, 20], 'xywh'); + expect(scaleBox(box, { from, to, resizeMode: 'stretch' })).toEqual({ + format: 'xywh', + xmin: 20, + ymin: 10, + w: 40, + h: 20, + }); + }); + + it('scales the centre and the extent of a cxcywh box', () => { + const box = decodeBox([50, 50, 20, 20], 'cxcywh'); + expect(scaleBox(box, { from, to, resizeMode: 'stretch' })).toEqual({ + format: 'cxcywh', + cx: 100, + cy: 50, + w: 40, + h: 20, + }); + }); + + it('keeps the aspect ratio of a letterboxed box', () => { + const box = decodeBox([0, 25, 100, 75], 'xyxy'); + expect(scaleBox(box, { from, to, resizeMode: 'letterbox' })).toEqual({ + format: 'xyxy', + xmin: 0, + ymin: 0, + xmax: 200, + ymax: 100, + }); + }); + + it('preserves the box format it was given', () => { + for (const format of ['xyxy', 'xywh', 'cxcywh'] as const) { + const scaled = scaleBox(decodeBox([1, 2, 3, 4], format), { + from, + to, + resizeMode: 'stretch', + }); + expect(scaled.format).toBe(format); + } + }); +}); + +describe('mulberry32', () => { + it('produces the same sequence for the same seed', () => { + const first = Array.from({ length: 8 }, mulberry32(42)); + const second = Array.from({ length: 8 }, mulberry32(42)); + expect(first).toEqual(second); + }); + + it('produces a different sequence for a different seed', () => { + expect(Array.from({ length: 8 }, mulberry32(1))).not.toEqual( + Array.from({ length: 8 }, mulberry32(2)) + ); + }); + + it('stays within [0, 1)', () => { + const next = mulberry32(7); + for (let i = 0; i < 2000; i++) { + const value = next(); + expect(value).toBeGreaterThanOrEqual(0); + expect(value).toBeLessThan(1); + } + }); + + it('does not immediately repeat', () => { + const next = mulberry32(0); + const values = Array.from({ length: 100 }, next); + expect(new Set(values).size).toBe(values.length); + }); +}); + +describe('randomNormal', () => { + it('returns a Float32Array of the requested length', () => { + const values = randomNormal(5, { seed: 1 }); + expect(values).toBeInstanceOf(Float32Array); + expect(values).toHaveLength(5); + }); + + it('fills an odd length completely — the transform emits values in pairs', () => { + const values = randomNormal(7, { seed: 3 }); + expect([...values].every(Number.isFinite)).toBe(true); + expect(values[6]).not.toBe(0); + }); + + it('reproduces its sequence from a seed', () => { + expect([...randomNormal(16, { seed: 99 })]).toEqual([...randomNormal(16, { seed: 99 })]); + }); + + it('approximates the requested mean and standard deviation', () => { + const size = 20000; + const values = randomNormal(size, { mean: 5, std: 2, seed: 12345 }); + + const mean = [...values].reduce((sum, v) => sum + v, 0) / size; + const variance = [...values].reduce((sum, v) => sum + (v - mean) ** 2, 0) / size; + + expect(mean).toBeCloseTo(5, 1); + expect(Math.sqrt(variance)).toBeCloseTo(2, 1); + }); + + it('defaults to a standard normal', () => { + const values = randomNormal(20000, { seed: 7 }); + const mean = [...values].reduce((sum, v) => sum + v, 0) / values.length; + expect(mean).toBeCloseTo(0, 1); + }); + + it('draws different values on successive unseeded calls', () => { + // The seed defaults to a timestamp, so two calls must not agree. + expect([...randomNormal(8)]).not.toEqual([...randomNormal(8)]); + }); +}); + +describe('image format tables', () => { + const formats = ['rgb', 'bgr', 'rgba', 'bgra', 'gray'] as const; + + it.each(formats)('%s has a channel count', (format) => { + expect(FORMAT_CHANNELS[format]).toBeGreaterThan(0); + }); + + it('gives every format a conversion to every other format', () => { + for (const from of formats) { + for (const to of formats) { + const code = FORMAT_CONVERSION[from][to]; + if (from === to) expect(code).toBeNull(); + else expect(code).toBe(`${from.toUpperCase()}2${to.toUpperCase()}`); + } + } + }); + + it('agrees with the channel counts implied by the format names', () => { + expect(FORMAT_CHANNELS).toEqual({ rgb: 3, bgr: 3, rgba: 4, bgra: 4, gray: 1 }); + }); +}); diff --git a/packages/react-native-executorch/__tests__/fetcher/androidBackend.test.ts b/packages/react-native-executorch/__tests__/fetcher/androidBackend.test.ts new file mode 100644 index 0000000000..2b8bdd95ee --- /dev/null +++ b/packages/react-native-executorch/__tests__/fetcher/androidBackend.test.ts @@ -0,0 +1,110 @@ +/** + * The Android download path. + * + * `src/fetcher/fetcher.ts` decides between the two backends once, at module + * scope (`const IS_ANDROID = Platform.OS === 'android'`), so exercising the + * Android branch means re-importing the module with a different `Platform`. + * That also re-instantiates the blob-util mock, so every handle used here has + * to come from the same fresh module registry — hence the `load()` helper + * rather than the file-level imports the other fetcher suites use. + */ +import type { Route } from '../support/blobUtilMock'; + +const URL_A = 'https://huggingface.co/software-mansion/model/resolve/v1/model.pte'; +const HF_COUNTER = 'https://huggingface.co/software-mansion/model/resolve/main/config.json'; + +type Android = { + download: typeof import('../../src/fetcher/fetcher').download; + serve: (url: string, route?: Route) => void; + paths: () => string[]; + readText: (path: string) => string | undefined; + countRequests: (method: string, url: string) => number; +}; + +const load = async (): Promise => { + jest.resetModules(); + // A Proxy rather than a spread: the `react-native` entry point defines its + // exports as lazy getters, and spreading it evaluates every one of them — + // including native modules like `DevMenu` that do not exist under Jest. + jest.doMock('react-native', () => { + const actual = jest.requireActual('react-native'); + return new Proxy(actual, { + get: (target, property) => + property === 'Platform' + ? { ...target.Platform, OS: 'android' } + : target[property as keyof typeof target], + }); + }); + + const blobUtil = await import('../support/blobUtilMock'); + const { download } = await import('../../src/fetcher/fetcher'); + const { setTelemetryEnabled } = await import('../../src/fetcher/telemetry'); + setTelemetryEnabled(false); + + // The freshly loaded fetcher talks to the freshly loaded mock, so the global + // `fetch` installed by the shared setup file has to be pointed at it. + globalThis.fetch = blobUtil.fakeFetch as unknown as typeof globalThis.fetch; + blobUtil.fakeNet.serve(HF_COUNTER); + + return { + download, + serve: blobUtil.fakeNet.serve, + paths: blobUtil.fakeFs.paths, + readText: blobUtil.fakeFs.readText, + countRequests: blobUtil.fakeNet.countRequests, + }; +}; + +afterEach(() => { + jest.dontMock('react-native'); + jest.resetModules(); +}); + +describe('download on Android', () => { + it('caches under the app-private external directory', async () => { + const android = await load(); + android.serve(URL_A, { body: 'model-bytes' }); + + const path = await android.download(URL_A); + + expect(path.startsWith('/fake/sdcard/react-native-executorch/')).toBe(true); + expect(android.readText(path)).toBe('model-bytes'); + }); + + it('downloads through a temporary file and moves it into place', async () => { + const android = await load(); + android.serve(URL_A); + + await android.download(URL_A); + + expect(android.paths().filter((p) => p.endsWith('.downloading'))).toEqual([]); + }); + + it('treats an empty response as a failure, since DownloadManager reports no status', async () => { + const android = await load(); + android.serve(URL_A, { body: '' }); + + await expect(android.download(URL_A)).rejects.toThrow(/empty response/); + expect(android.paths()).toEqual([]); + }); + + it('does not send a Range header — DownloadManager resumes on its own', async () => { + const android = await load(); + android.serve(URL_A); + + await android.download(URL_A); + + expect(android.countRequests('GET', URL_A)).toBe(1); + }); + + it('serves a second call from the cache', async () => { + const android = await load(); + android.serve(URL_A); + + const first = await android.download(URL_A); + const second = await android.download(URL_A); + + expect(second).toBe(first); + expect(android.countRequests('GET', URL_A)).toBe(1); + }); +}); diff --git a/packages/react-native-executorch/__tests__/fetcher/download.test.ts b/packages/react-native-executorch/__tests__/fetcher/download.test.ts new file mode 100644 index 0000000000..f52da37865 --- /dev/null +++ b/packages/react-native-executorch/__tests__/fetcher/download.test.ts @@ -0,0 +1,362 @@ +import { AbortError, download } from '../../src/fetcher/fetcher'; +import { setTelemetryEnabled } from '../../src/fetcher/telemetry'; +import { until } from '../support/async'; +import { deferred, fakeFs, fakeNet } from '../support/blobUtilMock'; + +// The cache path is derived from a djb2 hash of the URL, so tests locate files +// by suffix rather than by recomputing the hash. +const cachedPath = (basename: string): string | undefined => + fakeFs.paths().find((p) => p.endsWith(`_${basename}`)); + +const URL_A = 'https://huggingface.co/software-mansion/model/resolve/v1/model.pte'; +const URL_B = 'https://huggingface.co/software-mansion/model/resolve/v1/tokenizer.json'; +// The Hugging Face download counter fires for every SWM repo regardless of the +// telemetry opt-out, so it needs a route or `fakeNet` reports it as unhandled. +const HF_COUNTER = 'https://huggingface.co/software-mansion/model/resolve/main/config.json'; + +/** + * Waits until the download of `url` has actually issued its GET. + */ +const untilFetching = (url: string, count = 1) => + until(() => fakeNet.countRequests('GET', url) >= count, `the GET for ${url}`); + +beforeEach(() => { + // Analytics have their own suite; keeping them off here means an unregistered + // endpoint cannot fail an unrelated download test. + setTelemetryEnabled(false); + fakeNet.serve(HF_COUNTER); +}); + +afterEach(() => { + setTelemetryEnabled(true); +}); + +describe('download — resolution', () => { + it('passes a local path through untouched and reports completion', async () => { + const onProgress = jest.fn(); + await expect(download('/local/model.pte', { onProgress })).resolves.toBe('/local/model.pte'); + expect(onProgress).toHaveBeenCalledWith(1); + expect(fakeNet.requests()).toHaveLength(0); + }); + + it('downloads a remote URL and resolves with its local path', async () => { + fakeNet.serve(URL_A, { body: 'model-bytes' }); + + const path = await download(URL_A); + + expect(path).toBe(cachedPath('model.pte')); + expect(fakeFs.readText(path)).toBe('model-bytes'); + }); + + it('walks a nested config, replacing only the remote leaves', async () => { + fakeNet.serve(URL_A); + fakeNet.serve(URL_B); + + const config = { + modelPath: URL_A, + tokenizerPath: URL_B, + localPath: '/already/local.pte', + modelOpts: { labels: ['cat', 'dog'], normalizeOpts: { alpha: 0.5 } }, + }; + + const resolved = await download(config); + + expect(resolved.modelPath).toBe(cachedPath('model.pte')); + expect(resolved.tokenizerPath).toBe(cachedPath('tokenizer.json')); + expect(resolved.localPath).toBe('/already/local.pte'); + expect(resolved.modelOpts).toEqual(config.modelOpts); + }); + + it('returns the original object identity when nothing needed downloading', async () => { + const config = { modelPath: '/local/model.pte', modelOpts: { labels: ['a'] } }; + const resolved = await download(config); + expect(resolved).toBe(config); + }); + + it('keeps untouched sub-objects referentially stable', async () => { + fakeNet.serve(URL_A); + const config = { modelPath: URL_A, modelOpts: { labels: ['a'] } }; + + const resolved = await download(config); + + expect(resolved).not.toBe(config); + expect(resolved.modelOpts).toBe(config.modelOpts); + }); + + it('resolves arrays of URLs', async () => { + fakeNet.serve(URL_A); + fakeNet.serve(URL_B); + + const resolved = await download([URL_A, URL_B, '/local.pte']); + + expect(resolved).toEqual([cachedPath('model.pte'), cachedPath('tokenizer.json'), '/local.pte']); + }); + + it('fetches a URL repeated across fields only once', async () => { + fakeNet.serve(URL_A); + + const resolved = await download({ a: URL_A, b: URL_A }); + + expect(resolved.a).toBe(resolved.b); + expect(fakeNet.countRequests('GET', URL_A)).toBe(1); + }); + + it('leaves non-http strings alone', async () => { + const resolved = await download({ scheme: 'ftp://example.com/model.pte', name: 'whisper' }); + expect(resolved).toEqual({ scheme: 'ftp://example.com/model.pte', name: 'whisper' }); + }); +}); + +describe('download — caching', () => { + it('serves a second call from the cache without a request', async () => { + fakeNet.serve(URL_A); + + const first = await download(URL_A); + const requestsAfterFirst = fakeNet.countRequests('GET', URL_A); + const second = await download(URL_A); + + expect(second).toBe(first); + expect(fakeNet.countRequests('GET', URL_A)).toBe(requestsAfterFirst); + }); + + it('reports full progress immediately on a cache hit', async () => { + fakeNet.serve(URL_A); + await download(URL_A); + + const onProgress = jest.fn(); + await download(URL_A, { onProgress }); + + expect(onProgress).toHaveBeenLastCalledWith(1); + }); + + it('re-downloads and replaces the cached copy when forceDownload is set', async () => { + fakeNet.serve(URL_A, { body: 'v1' }); + const path = await download(URL_A); + expect(fakeFs.readText(path)).toBe('v1'); + + fakeNet.serve(URL_A, { body: 'v2-longer' }); + await download(URL_A, { forceDownload: true }); + + expect(fakeFs.readText(path)).toBe('v2-longer'); + expect(fakeNet.countRequests('GET', URL_A)).toBe(2); + }); +}); + +describe('download — progress', () => { + it('weights progress by byte size across several files', async () => { + fakeNet.serve(URL_A, { body: 'a'.repeat(90) }); + fakeNet.serve(URL_B, { body: 'b'.repeat(10) }); + + const seen: number[] = []; + await download({ model: URL_A, tokenizer: URL_B }, { onProgress: (p) => seen.push(p) }); + + // Every report is a valid fraction, monotonically bounded, ending at 1. + expect(seen.every((p) => p >= 0 && p <= 1)).toBe(true); + expect(seen.at(-1)).toBe(1); + // The 10-byte file finishing on its own can never carry progress past 10%. + expect(Math.max(...seen.filter((p) => p < 1))).toBeLessThan(1); + }); + + it('falls back to equal weighting when a content length is unavailable', async () => { + fakeNet.serve(URL_A, { headOk: false }); + fakeNet.serve(URL_B); + + const seen: number[] = []; + await download([URL_A, URL_B], { onProgress: (p) => seen.push(p) }); + + expect(seen.at(-1)).toBe(1); + }); + + it('never reports a fraction above 1', async () => { + fakeNet.serve(URL_A, { body: 'a'.repeat(4) }); + + const seen: number[] = []; + await download(URL_A, { onProgress: (p) => seen.push(p) }); + + expect(Math.max(...seen)).toBe(1); + }); +}); + +describe('download — failure handling', () => { + it('rejects on an HTTP error status', async () => { + fakeNet.serve(URL_A, { status: 404 }); + await expect(download(URL_A)).rejects.toThrow(/HTTP status 404/); + }); + + it('leaves no cached file behind after a failed download', async () => { + fakeNet.serve(URL_A, { status: 500 }); + await expect(download(URL_A)).rejects.toThrow(); + expect(cachedPath('model.pte')).toBeUndefined(); + }); + + it('propagates a transport error', async () => { + fakeNet.serve(URL_A, { error: new Error('network unreachable') }); + await expect(download(URL_A)).rejects.toThrow('network unreachable'); + }); + + it('retries a failed URL on the next call rather than caching the failure', async () => { + fakeNet.serve(URL_A, { status: 500 }); + await expect(download(URL_A)).rejects.toThrow(); + + fakeNet.serve(URL_A, { body: 'recovered' }); + const path = await download(URL_A); + expect(fakeFs.readText(path)).toBe('recovered'); + }); +}); + +describe('download — cancellation', () => { + it('rejects with an AbortError when the signal is already aborted', async () => { + fakeNet.serve(URL_A); + const controller = new AbortController(); + controller.abort(); + + const rejection = await download(URL_A, { signal: controller.signal }).catch((e) => e); + expect(rejection).toBeInstanceOf(AbortError); + expect(rejection.name).toBe('AbortError'); + }); + + it('rejects with an AbortError when aborted mid-flight', async () => { + const gate = deferred(); + fakeNet.serve(URL_A, { gate: gate.promise }); + + const controller = new AbortController(); + const rejection = download(URL_A, { signal: controller.signal }).catch((e) => e); + + await untilFetching(URL_A); + controller.abort(); + gate.resolve(); + + expect(await rejection).toBeInstanceOf(AbortError); + expect(cachedPath('model.pte')).toBeUndefined(); + }); + + it('rejects before issuing a request when aborted during the size probe', async () => { + fakeNet.serve(URL_A); + + const controller = new AbortController(); + const rejection = download(URL_A, { signal: controller.signal }).catch((e) => e); + controller.abort(); + + expect(await rejection).toBeInstanceOf(AbortError); + expect(fakeNet.countRequests('GET', URL_A)).toBe(0); + }); +}); + +describe('download — concurrent callers', () => { + it('shares one request between callers that ask for the same URL', async () => { + const gate = deferred(); + fakeNet.serve(URL_A, { gate: gate.promise }); + + const first = download(URL_A); + const second = download(URL_A); + gate.resolve(); + + expect(await first).toBe(await second); + expect(fakeNet.countRequests('GET', URL_A)).toBe(1); + }); + + it('fans progress out to every joined caller', async () => { + const gate = deferred(); + fakeNet.serve(URL_A, { gate: gate.promise }); + + const firstProgress = jest.fn(); + const secondProgress = jest.fn(); + const first = download(URL_A, { onProgress: firstProgress }); + const second = download(URL_A, { onProgress: secondProgress }); + gate.resolve(); + await Promise.all([first, second]); + + expect(firstProgress).toHaveBeenLastCalledWith(1); + expect(secondProgress).toHaveBeenLastCalledWith(1); + }); + + it('keeps the shared request alive when only one caller aborts', async () => { + const gate = deferred(); + fakeNet.serve(URL_A, { gate: gate.promise }); + + const controller = new AbortController(); + const leavingRejection = download(URL_A, { signal: controller.signal }).catch((e) => e); + const staying = download(URL_A); + + await untilFetching(URL_A); + controller.abort(); + gate.resolve(); + + expect(await leavingRejection).toBeInstanceOf(AbortError); + expect(await staying).toBe(cachedPath('model.pte')); + expect(fakeNet.countRequests('GET', URL_A)).toBe(1); + }); + + it('starts a fresh request after a shared one was fully abandoned', async () => { + const gate = deferred(); + fakeNet.serve(URL_A, { gate: gate.promise }); + + const controller = new AbortController(); + const abandoned = download(URL_A, { signal: controller.signal }).catch((e) => e); + await untilFetching(URL_A); + controller.abort(); + gate.resolve(); + expect(await abandoned).toBeInstanceOf(AbortError); + + fakeNet.serve(URL_A, { body: 'second attempt' }); + const path = await download(URL_A); + expect(fakeFs.readText(path)).toBe('second attempt'); + expect(fakeNet.countRequests('GET', URL_A)).toBe(2); + }); +}); + +describe('download — iOS resume', () => { + /** + * Stages the aftermath of an interrupted download: the cached file is gone + * and `partial` bytes are sitting next to it. The cache path is only known + * after one successful download, so it is learned and then undone. + */ + const stagePartial = async (partial: string): Promise => { + const path = await download(URL_A); + fakeFs.remove(path); + fakeFs.write(`${path}.partial`, partial); + return path; + }; + + it('resumes from a partial file with a Range request', async () => { + fakeNet.serve(URL_A, { body: 'abcdefgh' }); + const path = await stagePartial('abc'); + const before = fakeNet.requests().length; + + await download(URL_A); + + const ranged = fakeNet + .requests() + .slice(before) + .find((r) => r.headers.Range !== undefined); + expect(ranged?.headers.Range).toBe('bytes=3-'); + expect(fakeFs.readText(path)).toBe('abcdefgh'); + expect(fakeFs.has(`${path}.partial`)).toBe(false); + }); + + it('replaces the partial when the server ignores the Range header', async () => { + fakeNet.serve(URL_A, { body: 'abcdefgh', supportsRange: false }); + const path = await stagePartial('XX'); + + await download(URL_A); + + expect(fakeFs.readText(path)).toBe('abcdefgh'); + }); + + it('treats a 416 as "the partial already holds everything"', async () => { + fakeNet.serve(URL_A, { body: 'abcd' }); + const path = await stagePartial('abcd'); + + await download(URL_A); + + expect(fakeFs.readText(path)).toBe('abcd'); + }); + + it('leaves no temporary files behind on success', async () => { + fakeNet.serve(URL_A); + fakeNet.serve(URL_B); + await download([URL_A, URL_B]); + + expect(fakeFs.paths().filter((p) => /\.(partial|chunk|downloading)$/.test(p))).toEqual([]); + }); +}); diff --git a/packages/react-native-executorch/__tests__/fetcher/telemetry.test.ts b/packages/react-native-executorch/__tests__/fetcher/telemetry.test.ts new file mode 100644 index 0000000000..5cb66f9ff5 --- /dev/null +++ b/packages/react-native-executorch/__tests__/fetcher/telemetry.test.ts @@ -0,0 +1,153 @@ +import { Platform } from 'react-native'; + +import { + setTelemetryEnabled, + triggerDownloadEvent, + triggerHuggingFaceDownloadCounter, +} from '../../src/fetcher/telemetry'; +import { fakeJsi } from '../support/fakeJsi'; +import { flush } from '../support/async'; +import { fakeNet } from '../support/blobUtilMock'; + +const ANALYTICS = 'https://ai.swmansion.com/telemetry/downloads/api/downloads'; +const SWM_MODEL = 'https://huggingface.co/software-mansion/whisper-tiny/resolve/v1/model.pte'; +const SWM_COUNTER = 'https://huggingface.co/software-mansion/whisper-tiny/resolve/main/config.json'; + +/** The body the analytics endpoint was posted, parsed. */ +const postedPayload = (): Record => { + const call = (globalThis.fetch as jest.Mock).mock.calls.find(([url]) => url === ANALYTICS); + if (!call) throw new Error('the analytics endpoint was not called'); + return JSON.parse(call[1].body); +}; + +beforeEach(() => { + fakeNet.serve(ANALYTICS); + fakeNet.serve(SWM_COUNTER); + globalThis.fetch = jest.fn(globalThis.fetch); + setTelemetryEnabled(true); +}); + +afterEach(() => { + setTelemetryEnabled(true); +}); + +describe('triggerHuggingFaceDownloadCounter', () => { + it('HEADs the repo config.json for a Software Mansion repo', async () => { + triggerHuggingFaceDownloadCounter(SWM_MODEL); + await flush(); + + expect(globalThis.fetch).toHaveBeenCalledWith(SWM_COUNTER, { method: 'HEAD' }); + }); + + it('fires even when analytics are opted out of', async () => { + setTelemetryEnabled(false); + triggerHuggingFaceDownloadCounter(SWM_MODEL); + await flush(); + + expect(fakeNet.countRequests('HEAD', SWM_COUNTER)).toBe(1); + }); + + it.each([ + ['another Hugging Face org', 'https://huggingface.co/other-org/model/resolve/v1/model.pte'], + ['a non-Hugging Face host', 'https://example.com/software-mansion/model.pte'], + ])('does nothing for %s', async (_label, url) => { + triggerHuggingFaceDownloadCounter(url); + await flush(); + + expect(globalThis.fetch).not.toHaveBeenCalled(); + }); + + it('swallows a malformed URI rather than failing the download', async () => { + expect(() => triggerHuggingFaceDownloadCounter('not a url')).not.toThrow(); + }); + + it('swallows a rejected request', async () => { + fakeNet.serve(SWM_COUNTER, { error: new Error('offline') }); + expect(() => triggerHuggingFaceDownloadCounter(SWM_MODEL)).not.toThrow(); + await flush(); + }); +}); + +describe('triggerDownloadEvent', () => { + it('posts the model name derived from the URI, without its extension', async () => { + triggerDownloadEvent(SWM_MODEL); + await flush(); + + expect(postedPayload().modelName).toBe('model'); + }); + + it('reports the platform and the emulator flag from the native installer', async () => { + fakeJsi.setIsEmulator(true); + triggerDownloadEvent(SWM_MODEL); + await flush(); + + expect(postedPayload()).toMatchObject({ isEmulator: true, platform: Platform.OS }); + }); + + it('sends nothing once analytics are opted out of', async () => { + setTelemetryEnabled(false); + triggerDownloadEvent(SWM_MODEL); + await flush(); + + expect(globalThis.fetch).not.toHaveBeenCalled(); + }); + + it('resumes sending when analytics are opted back in', async () => { + setTelemetryEnabled(false); + triggerDownloadEvent(SWM_MODEL); + setTelemetryEnabled(true); + triggerDownloadEvent(SWM_MODEL); + await flush(); + + expect(fakeNet.countRequests('POST', ANALYTICS)).toBe(1); + }); + + it('swallows a rejected request', async () => { + fakeNet.serve(ANALYTICS, { error: new Error('offline') }); + expect(() => triggerDownloadEvent(SWM_MODEL)).not.toThrow(); + await flush(); + }); + + describe('country code', () => { + const withLocale = async (locale: string): Promise => { + const original = Intl.DateTimeFormat; + jest + .spyOn(Intl, 'DateTimeFormat') + .mockImplementation( + () => ({ resolvedOptions: () => ({ locale }) }) as unknown as Intl.DateTimeFormat + ); + try { + triggerDownloadEvent(SWM_MODEL); + await flush(); + return postedPayload().countryCode; + } finally { + (Intl as { DateTimeFormat: typeof Intl.DateTimeFormat }).DateTimeFormat = original; + } + }; + + it.each([ + ['en-US', 'US'], + ['pt-BR', 'BR'], + ['es-419', '419'], + ['de-DE-u-ca-gregory', 'DE'], + ['zh-Hans-CN', 'CN'], + ])('reads the region out of %s as %s', async (locale, expected) => { + expect(await withLocale(locale)).toBe(expected); + }); + + it.each([ + // A language-only locale must not be misread as a country: 'de' is not + // Germany, 'uk' is Ukrainian rather than the United Kingdom, and 'sv' is + // Swedish rather than El Salvador. + ['de'], + ['uk'], + ['sv'], + ])('reports UNKNOWN for the language-only locale %s', async (locale) => { + expect(await withLocale(locale)).toBe('UNKNOWN'); + }); + + it('stops at an extension singleton rather than reading past it', async () => { + expect(await withLocale('en-u-nu-latn')).toBe('UNKNOWN'); + }); + }); +}); From a946a9d58573235eb06ed1f8eebebfe8eb9eabff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20S=C5=82uszniak?= Date: Fri, 7 Aug 2026 16:21:11 +0200 Subject: [PATCH 3/7] test(ts): cover the task pipelines and the React hooks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One suite per pipeline: which model signatures it accepts, that a mismatch is rejected with a message naming the mismatch, the postprocessing that is the pipeline's own work (softmax ordering, NMS suppression, argmax colormaps, sigmoid grayscale masks, coordinate scaling back through letterboxing), every option and per-call override, and that `dispose()` releases everything. The pipelines whose behavior depends on real model weights — Whisper, VAD, SDXS, keypoints, instance segmentation — get schema acceptance, rejection and full disposal instead, including Whisper's nested tokenizer and VAD pipeline. `tasks/constructionFailure.test.ts` records a leak the suites surfaced: a `create` factory that throws after `loadModel` abandons the native model, and the caller never receives a `dispose` to release it. The tests assert the current behavior so it flips loudly once fixed. Hook suites cover the lifecycle apps depend on: disposal on unmount and on config change, the create-after-unmount race, config identity by value, download cache hits, preventLoad, and errors surfaced through the shared field. --- .../__tests__/hooks/taskHooks.test.ts | 196 ++++++++++ .../__tests__/hooks/useModel.test.ts | 157 ++++++++ .../hooks/useResourceDownload.test.ts | 175 +++++++++ .../__tests__/tasks/classification.test.ts | 170 +++++++++ .../tasks/constructionFailure.test.ts | 99 +++++ .../__tests__/tasks/embedding.test.ts | 199 ++++++++++ .../__tests__/tasks/objectDetection.test.ts | 212 +++++++++++ .../__tests__/tasks/remainingTasks.test.ts | 347 ++++++++++++++++++ .../tasks/semanticSegmentation.test.ts | 179 +++++++++ .../__tests__/tasks/styleTransfer.test.ts | 108 ++++++ .../__tests__/tasks/tokenization.test.ts | 99 +++++ 11 files changed, 1941 insertions(+) create mode 100644 packages/react-native-executorch/__tests__/hooks/taskHooks.test.ts create mode 100644 packages/react-native-executorch/__tests__/hooks/useModel.test.ts create mode 100644 packages/react-native-executorch/__tests__/hooks/useResourceDownload.test.ts create mode 100644 packages/react-native-executorch/__tests__/tasks/classification.test.ts create mode 100644 packages/react-native-executorch/__tests__/tasks/constructionFailure.test.ts create mode 100644 packages/react-native-executorch/__tests__/tasks/embedding.test.ts create mode 100644 packages/react-native-executorch/__tests__/tasks/objectDetection.test.ts create mode 100644 packages/react-native-executorch/__tests__/tasks/remainingTasks.test.ts create mode 100644 packages/react-native-executorch/__tests__/tasks/semanticSegmentation.test.ts create mode 100644 packages/react-native-executorch/__tests__/tasks/styleTransfer.test.ts create mode 100644 packages/react-native-executorch/__tests__/tasks/tokenization.test.ts diff --git a/packages/react-native-executorch/__tests__/hooks/taskHooks.test.ts b/packages/react-native-executorch/__tests__/hooks/taskHooks.test.ts new file mode 100644 index 0000000000..d3034a2306 --- /dev/null +++ b/packages/react-native-executorch/__tests__/hooks/taskHooks.test.ts @@ -0,0 +1,196 @@ +/** + * The path an app actually takes: a `use` hook resolving a remote config, + * loading the pipeline, exposing its methods, and releasing native memory on + * unmount. + */ +import { renderHook, waitFor } from '@testing-library/react-native'; + +import { f32, method } from '../../src/core/schema'; +import { setTelemetryEnabled } from '../../src/fetcher/telemetry'; +import { useClassifier } from '../../src/hooks/useClassifier'; +import { useTokenizer } from '../../src/hooks/useTokenizer'; +import { deferred, fakeNet } from '../support/blobUtilMock'; +import { cachePathFor } from '../support/cachePath'; +import { fakeJsi } from '../support/fakeJsi'; +import { STRETCH_PREPROCESSING, exported, imageBuffer, writesOutputs } from '../support/fixtures'; +import { allowNativeLeaks } from '../support/setup'; + +const MODEL_URL = 'https://huggingface.co/software-mansion/model/resolve/v1/model.pte'; +const TOKENIZER_URL = 'https://huggingface.co/software-mansion/model/resolve/v1/tokenizer.json'; +const HF_COUNTER = 'https://huggingface.co/software-mansion/model/resolve/main/config.json'; + +const LABELS = ['cat', 'dog'] as const; +const config = { modelPath: MODEL_URL, modelOpts: { ...STRETCH_PREPROCESSING, labels: LABELS } }; + +/** + * A hook downloads and loads in one uninterrupted pass, so the fake program has + * to sit at its final cache path before the hook renders. + */ +const registerModelAtCachePath = (outputs = [f32(1, 2)]) => + fakeJsi.registerModel(cachePathFor(MODEL_URL), { + schema: exported(method('forward', [f32(1, 3, 4, 4)], outputs)), + execute: writesOutputs([0, 5]), + }); + +beforeEach(() => { + setTelemetryEnabled(false); + fakeNet.serve(HF_COUNTER); + fakeNet.serve(MODEL_URL); + fakeNet.serve(TOKENIZER_URL); +}); + +afterEach(() => { + setTelemetryEnabled(true); +}); + +describe('useClassifier', () => { + it('downloads the model to the path the pipeline then loads from', async () => { + // Guards the duplicated derivation in `support/cachePath.ts`. + registerModelAtCachePath(); + const { result } = await renderHook(() => useClassifier(config)); + + await waitFor(() => expect(result.current.resource).toBeDefined()); + + expect(result.current.resource?.modelPath).toBe(cachePathFor(MODEL_URL)); + }); + + it('reports not-ready while the download is still in flight', async () => { + registerModelAtCachePath(); + const gate = deferred(); + fakeNet.serve(MODEL_URL, { gate: gate.promise }); + + const { result } = await renderHook(() => useClassifier(config)); + + expect(result.current.isReady).toBe(false); + expect(result.current.classify).toBeUndefined(); + expect(result.current.error).toBeNull(); + + gate.resolve(); + await waitFor(() => expect(result.current.isReady).toBe(true)); + expect(result.current.classify).toBeInstanceOf(Function); + expect(result.current.classifyWorklet).toBeInstanceOf(Function); + }); + + it('exposes the labels from the config before anything has loaded', async () => { + fakeNet.serve(MODEL_URL, { gate: deferred().promise }); + + const { result } = await renderHook(() => useClassifier(config)); + + expect(result.current.isReady).toBe(false); + expect(result.current.labels).toBe(LABELS); + }); + + it('runs inference through the hook-provided method', async () => { + registerModelAtCachePath(); + const { result } = await renderHook(() => useClassifier(config)); + await waitFor(() => expect(result.current.isReady).toBe(true)); + + const results = await result.current.classify!(imageBuffer(8, 8)); + + expect(results.map((r) => r.label)).toEqual(['dog', 'cat']); + }); + + it('releases every native resource on unmount', async () => { + registerModelAtCachePath(); + const { result, unmount } = await renderHook(() => useClassifier(config)); + await waitFor(() => expect(result.current.isReady).toBe(true)); + + await unmount(); + + expect(fakeJsi.liveTensors()).toBe(0); + expect(fakeJsi.liveModels()).toEqual([]); + }); + + it('surfaces a download failure through the shared error field', async () => { + fakeNet.serve(MODEL_URL, { status: 403 }); + + const { result } = await renderHook(() => useClassifier(config)); + + await waitFor(() => expect(result.current.error).not.toBeNull()); + expect(result.current.error?.message).toMatch(/HTTP status 403/); + expect(result.current.isReady).toBe(false); + }); + + it('surfaces a model-compilation failure through the same error field', async () => { + // A model whose output dimension does not match the two configured labels. + registerModelAtCachePath([f32(1, 9)]); + + const { result } = await renderHook(() => useClassifier(config)); + + await waitFor(() => expect(result.current.error).not.toBeNull()); + expect(result.current.error?.message).toMatch(/labels length \(2\)/); + expect(result.current.isReady).toBe(false); + + // The failed construction abandons the native model it had already loaded, + // and the hook has no handle to release — this is the app-level shape of + // the leak recorded in `tasks/constructionFailure.test.ts`. + expect(fakeJsi.liveModels()).toEqual([cachePathFor(MODEL_URL)]); + allowNativeLeaks(); + }); + + it('loads nothing while preventLoad is set', async () => { + registerModelAtCachePath(); + const { result } = await renderHook(() => useClassifier(config, { preventLoad: true })); + + expect(result.current.isReady).toBe(false); + expect(fakeNet.countRequests('GET', MODEL_URL)).toBe(0); + }); + + it('rebuilds the pipeline when the config changes, releasing the old one', async () => { + const otherUrl = MODEL_URL.replace('model.pte', 'other.pte'); + fakeNet.serve(otherUrl); + registerModelAtCachePath(); + fakeJsi.registerModel(cachePathFor(otherUrl), { + schema: exported(method('forward', [f32(1, 3, 4, 4)], [f32(1, 2)])), + }); + + const { result, rerender } = await renderHook( + ({ modelPath }: { modelPath: string }) => useClassifier({ ...config, modelPath }), + { initialProps: { modelPath: MODEL_URL } } + ); + await waitFor(() => expect(result.current.isReady).toBe(true)); + + await rerender({ modelPath: otherUrl }); + await waitFor(() => expect(result.current.resource?.modelPath).toBe(cachePathFor(otherUrl))); + + expect(fakeJsi.liveModels()).toEqual([cachePathFor(otherUrl)]); + }); +}); + +describe('useTokenizer', () => { + const registerTokenizer = () => + fakeJsi.registerTokenizer(cachePathFor(TOKENIZER_URL), { tokens: ['hello', 'world'] }); + + it('resolves a remote tokenizer and exposes its operations', async () => { + registerTokenizer(); + const { result } = await renderHook(() => useTokenizer(TOKENIZER_URL)); + + await waitFor(() => expect(result.current.isReady).toBe(true)); + + expect(await result.current.encode!('hello world')).toEqual(Int32Array.from([0, 1])); + expect(result.current.getVocabSize!()).toBe(2); + }); + + it('releases the native tokenizer on unmount', async () => { + registerTokenizer(); + const { result, unmount } = await renderHook(() => useTokenizer(TOKENIZER_URL)); + await waitFor(() => expect(result.current.isReady).toBe(true)); + + await unmount(); + + expect(fakeJsi.liveTokenizers()).toEqual([]); + }); +}); + +describe('use hooks — shared contract', () => { + it('returns the loading fields every app relies on', async () => { + const { result } = await renderHook(() => useClassifier(config, { preventLoad: true })); + + expect(result.current).toMatchObject({ + isReady: expect.any(Boolean), + error: null, + downloadProgress: expect.any(Number), + }); + expect('resource' in result.current).toBe(true); + }); +}); diff --git a/packages/react-native-executorch/__tests__/hooks/useModel.test.ts b/packages/react-native-executorch/__tests__/hooks/useModel.test.ts new file mode 100644 index 0000000000..74015e829a --- /dev/null +++ b/packages/react-native-executorch/__tests__/hooks/useModel.test.ts @@ -0,0 +1,157 @@ +import { act, renderHook, waitFor } from '@testing-library/react-native'; + +import { useModel } from '../../src/hooks/useModel'; +import { deferred } from '../support/blobUtilMock'; + +type Instance = { dispose: jest.Mock; id: string }; + +/** + * A factory returning a fresh disposable per call, recording every instance. + */ +const factoryOf = (instances: Instance[]) => + jest.fn(async (config: { id: string }) => { + const instance = { dispose: jest.fn(), id: config.id }; + instances.push(instance); + return instance; + }); + +describe('useModel', () => { + it('starts with no model and no error', async () => { + const { result } = await renderHook(() => useModel(factoryOf([]), null)); + expect(result.current).toEqual({ model: null, error: null }); + }); + + it('exposes the instance once the factory resolves', async () => { + const instances: Instance[] = []; + const { result } = await renderHook(() => useModel(factoryOf(instances), { id: 'a' })); + + await waitFor(() => expect(result.current.model).not.toBeNull()); + expect(result.current.model).toBe(instances[0]); + }); + + it('does not create anything for a null config', async () => { + const factory = factoryOf([]); + await renderHook(() => useModel(factory, null)); + expect(factory).not.toHaveBeenCalled(); + }); + + it('disposes the instance on unmount', async () => { + const instances: Instance[] = []; + const { result, unmount } = await renderHook(() => useModel(factoryOf(instances), { id: 'a' })); + await waitFor(() => expect(result.current.model).not.toBeNull()); + + await unmount(); + + expect(instances[0]!.dispose).toHaveBeenCalledTimes(1); + }); + + it('disposes the previous instance when the config changes', async () => { + const instances: Instance[] = []; + const factory = factoryOf(instances); + const { result, rerender } = await renderHook( + ({ config }: { config: { id: string } }) => useModel(factory, config), + { initialProps: { config: { id: 'a' } } } + ); + await waitFor(() => expect(result.current.model).not.toBeNull()); + + await rerender({ config: { id: 'b' } }); + await waitFor(() => expect(result.current.model?.id).toBe('b')); + + expect(instances[0]!.dispose).toHaveBeenCalledTimes(1); + expect(instances[1]!.dispose).not.toHaveBeenCalled(); + }); + + it('keys the config by value, so an equal inline object does not rebuild', async () => { + const instances: Instance[] = []; + const factory = factoryOf(instances); + const { result, rerender } = await renderHook( + ({ config }: { config: { id: string } }) => useModel(factory, config), + { initialProps: { config: { id: 'a' } } } + ); + await waitFor(() => expect(result.current.model).not.toBeNull()); + + // A new object with identical contents — the common case for an inline + // config literal being re-created on every render. + await rerender({ config: { id: 'a' } }); + + expect(factory).toHaveBeenCalledTimes(1); + expect(instances).toHaveLength(1); + }); + + it('disposes an instance that arrives after unmount', async () => { + const gate = deferred(); + const instance = { dispose: jest.fn(), id: 'late' }; + const factory = jest.fn(async () => { + await gate.promise; + return instance; + }); + + const { unmount } = await renderHook(() => useModel(factory, { id: 'late' })); + await unmount(); + await act(async () => { + gate.resolve(); + await gate.promise; + }); + + // Nothing holds a reference to it any more, so the hook has to release it. + expect(instance.dispose).toHaveBeenCalledTimes(1); + }); + + it('surfaces a factory rejection as an Error', async () => { + const factory = jest.fn(async () => { + throw new Error('spec mismatch'); + }); + + const { result } = await renderHook(() => useModel(factory, { id: 'a' })); + + await waitFor(() => expect(result.current.error).not.toBeNull()); + expect(result.current.error?.message).toBe('spec mismatch'); + expect(result.current.model).toBeNull(); + }); + + it('wraps a non-Error rejection in an Error', async () => { + const factory = jest.fn(async () => { + throw 'plain failure'; + }); + + const { result } = await renderHook(() => useModel(factory, { id: 'a' })); + + await waitFor(() => expect(result.current.error).toBeInstanceOf(Error)); + expect(result.current.error?.message).toBe('plain failure'); + }); + + it('clears a previous error when the config changes', async () => { + let shouldFail = true; + const factory = jest.fn(async (config: { id: string }) => { + if (shouldFail) throw new Error('spec mismatch'); + return { dispose: jest.fn(), id: config.id }; + }); + + const { result, rerender } = await renderHook( + ({ config }: { config: { id: string } }) => useModel(factory, config), + { initialProps: { config: { id: 'bad' } } } + ); + await waitFor(() => expect(result.current.error).not.toBeNull()); + + shouldFail = false; + await rerender({ config: { id: 'good' } }); + + await waitFor(() => expect(result.current.model).not.toBeNull()); + expect(result.current.error).toBeNull(); + }); + + it('clears the model when the config becomes null', async () => { + const instances: Instance[] = []; + const factory = factoryOf(instances); + const { result, rerender } = await renderHook( + ({ config }: { config: { id: string } | null }) => useModel(factory, config), + { initialProps: { config: { id: 'a' } as { id: string } | null } } + ); + await waitFor(() => expect(result.current.model).not.toBeNull()); + + await rerender({ config: null }); + + await waitFor(() => expect(result.current.model).toBeNull()); + expect(instances[0]!.dispose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/react-native-executorch/__tests__/hooks/useResourceDownload.test.ts b/packages/react-native-executorch/__tests__/hooks/useResourceDownload.test.ts new file mode 100644 index 0000000000..19716146e4 --- /dev/null +++ b/packages/react-native-executorch/__tests__/hooks/useResourceDownload.test.ts @@ -0,0 +1,175 @@ +import { renderHook, waitFor } from '@testing-library/react-native'; + +import { useResourceDownload } from '../../src/hooks/useResourceDownload'; +import { setTelemetryEnabled } from '../../src/fetcher/telemetry'; +import { deferred, fakeFs, fakeNet } from '../support/blobUtilMock'; + +const MODEL_URL = 'https://huggingface.co/software-mansion/model/resolve/v1/model.pte'; +const TOKENIZER_URL = 'https://huggingface.co/software-mansion/model/resolve/v1/tokenizer.json'; +const HF_COUNTER = 'https://huggingface.co/software-mansion/model/resolve/main/config.json'; + +const cachedPath = (basename: string): string | undefined => + fakeFs.paths().find((p) => p.endsWith(`_${basename}`)); + +beforeEach(() => { + setTelemetryEnabled(false); + fakeNet.serve(HF_COUNTER); +}); + +afterEach(() => { + setTelemetryEnabled(true); +}); + +describe('useResourceDownload', () => { + it('starts with no resource and zero progress', async () => { + fakeNet.serve(MODEL_URL, { gate: deferred().promise }); + + const { result } = await renderHook(() => useResourceDownload(MODEL_URL)); + + expect(result.current).toEqual({ + resource: undefined, + downloadProgress: 0, + downloadError: null, + }); + }); + + it('resolves a local path without touching the network', async () => { + const { result } = await renderHook(() => useResourceDownload('/local/model.pte')); + + await waitFor(() => expect(result.current.resource).toBe('/local/model.pte')); + expect(result.current.downloadProgress).toBe(100); + expect(fakeNet.requests()).toHaveLength(0); + }); + + it('resolves a whole config, replacing only the remote leaves', async () => { + fakeNet.serve(MODEL_URL); + fakeNet.serve(TOKENIZER_URL); + const config = { + modelPath: MODEL_URL, + tokenizerPath: TOKENIZER_URL, + modelOpts: { labels: ['a', 'b'] }, + }; + + const { result } = await renderHook(() => useResourceDownload(config)); + + await waitFor(() => expect(result.current.resource).toBeDefined()); + expect(result.current.resource).toEqual({ + modelPath: cachedPath('model.pte'), + tokenizerPath: cachedPath('tokenizer.json'), + modelOpts: { labels: ['a', 'b'] }, + }); + }); + + it('reports 100 once every file has arrived', async () => { + fakeNet.serve(MODEL_URL); + + const { result } = await renderHook(() => useResourceDownload(MODEL_URL)); + + await waitFor(() => expect(result.current.downloadProgress).toBe(100)); + }); + + it('surfaces a download failure and leaves the resource unset', async () => { + fakeNet.serve(MODEL_URL, { status: 404 }); + + const { result } = await renderHook(() => useResourceDownload(MODEL_URL)); + + await waitFor(() => expect(result.current.downloadError).not.toBeNull()); + expect(result.current.downloadError?.message).toMatch(/HTTP status 404/); + expect(result.current.resource).toBeUndefined(); + }); + + it('downloads nothing while preventLoad is set', async () => { + fakeNet.serve(MODEL_URL); + + const { result } = await renderHook(() => + useResourceDownload(MODEL_URL, { preventLoad: true }) + ); + + expect(result.current.resource).toBeUndefined(); + expect(fakeNet.countRequests('GET', MODEL_URL)).toBe(0); + }); + + it('starts downloading once preventLoad is lifted', async () => { + fakeNet.serve(MODEL_URL); + const { result, rerender } = await renderHook( + ({ preventLoad }: { preventLoad: boolean }) => + useResourceDownload(MODEL_URL, { preventLoad }), + { initialProps: { preventLoad: true } } + ); + + await rerender({ preventLoad: false }); + + await waitFor(() => expect(result.current.resource).toBe(cachedPath('model.pte'))); + }); + + it('resets its state when preventLoad is switched back on', async () => { + fakeNet.serve(MODEL_URL); + const { result, rerender } = await renderHook( + ({ preventLoad }: { preventLoad: boolean }) => + useResourceDownload(MODEL_URL, { preventLoad }), + { initialProps: { preventLoad: false } } + ); + await waitFor(() => expect(result.current.resource).toBeDefined()); + + await rerender({ preventLoad: true }); + + expect(result.current.resource).toBeUndefined(); + expect(result.current.downloadProgress).toBe(0); + }); + + it('keys the config by value, so an equal inline object does not re-download', async () => { + fakeNet.serve(MODEL_URL); + const { result, rerender } = await renderHook( + ({ config }: { config: { modelPath: string } }) => useResourceDownload(config), + { initialProps: { config: { modelPath: MODEL_URL } } } + ); + await waitFor(() => expect(result.current.resource).toBeDefined()); + const resolved = result.current.resource; + + await rerender({ config: { modelPath: MODEL_URL } }); + + expect(result.current.resource).toBe(resolved); + }); + + it('re-resolves when the config actually changes', async () => { + fakeNet.serve(MODEL_URL); + fakeNet.serve(TOKENIZER_URL); + const { result, rerender } = await renderHook( + ({ url }: { url: string }) => useResourceDownload(url), + { initialProps: { url: MODEL_URL } } + ); + await waitFor(() => expect(result.current.resource).toBe(cachedPath('model.pte'))); + + await rerender({ url: TOKENIZER_URL }); + + await waitFor(() => expect(result.current.resource).toBe(cachedPath('tokenizer.json'))); + }); + + it('does not report an error after being unmounted mid-download', async () => { + const gate = deferred(); + fakeNet.serve(MODEL_URL, { gate: gate.promise, status: 500 }); + const { unmount } = await renderHook(() => useResourceDownload(MODEL_URL)); + + await unmount(); + gate.resolve(); + + // An update after unmount would warn or throw; getting here quietly is the + // assertion. A tick lets the aborted download settle first. + await new Promise((resolve) => setImmediate(resolve)); + }); + + it('re-downloads when forceDownload is set', async () => { + fakeNet.serve(MODEL_URL, { body: 'v1' }); + const { result, rerender } = await renderHook( + ({ force }: { force: boolean }) => useResourceDownload(MODEL_URL, { forceDownload: force }), + { initialProps: { force: false } } + ); + await waitFor(() => expect(result.current.resource).toBeDefined()); + + fakeNet.serve(MODEL_URL, { body: 'v2' }); + await rerender({ force: true }); + await waitFor(() => expect(fakeNet.countRequests('GET', MODEL_URL)).toBe(2)); + + expect(fakeFs.readText(cachedPath('model.pte')!)).toBe('v2'); + }); +}); diff --git a/packages/react-native-executorch/__tests__/tasks/classification.test.ts b/packages/react-native-executorch/__tests__/tasks/classification.test.ts new file mode 100644 index 0000000000..3ac20e0a4b --- /dev/null +++ b/packages/react-native-executorch/__tests__/tasks/classification.test.ts @@ -0,0 +1,170 @@ +import { f32, method } from '../../src/core/schema'; +import { createClassifier } from '../../src/extensions/cv/tasks/classification'; +import { fakeJsi } from '../support/fakeJsi'; +import { tracked } from '../support/lifetime'; +import { STRETCH_PREPROCESSING, exported, imageBuffer, writesOutputs } from '../support/fixtures'; +import { allowNativeLeaks } from '../support/setup'; + +const MODEL_PATH = '/models/classifier.pte'; +const LABELS = ['cat', 'dog', 'bird'] as const; + +/** Logits chosen so softmax orders them bird > cat > dog. */ +const LOGITS = [1, 0, 2]; + +const registerBatched = (labels: readonly string[] = LABELS) => { + fakeJsi.registerModel(MODEL_PATH, { + schema: exported(method('forward', [f32(1, 3, 4, 4)], [f32(1, labels.length)])), + execute: writesOutputs(LOGITS), + }); +}; + +const config = (labels: readonly string[] = LABELS) => ({ + modelPath: MODEL_PATH, + modelOpts: { ...STRETCH_PREPROCESSING, labels }, +}); + +describe('createClassifier — model acceptance', () => { + it('accepts a batched [1, 3, H, W] -> [1, N] model', async () => { + registerBatched(); + const classifier = tracked(await createClassifier(config())); + expect(classifier.classify).toBeInstanceOf(Function); + }); + + it('accepts an unbatched [3, H, W] -> [N] model', async () => { + fakeJsi.registerModel(MODEL_PATH, { + schema: exported(method('forward', [f32(3, 4, 4)], [f32(3)])), + execute: writesOutputs(LOGITS), + }); + + const classifier = tracked(await createClassifier(config())); + expect(await classifier.classify(imageBuffer(4, 4))).toHaveLength(3); + }); + + it('rejects a model whose signature matches no variant', async () => { + fakeJsi.registerModel(MODEL_PATH, { + schema: exported(method('forward', [f32(1, 3, 4, 4)], [f32(1, 3), f32(1, 3)])), + }); + + await expect(createClassifier(config())).rejects.toThrow(/doesn't match any of the provided/); + allowNativeLeaks(); // see `tasks/constructionFailure.test.ts` + }); + + it('rejects a labels array that does not match the output dimension', async () => { + registerBatched(); + await expect(createClassifier(config(['cat', 'dog']))).rejects.toThrow( + /labels length \(2\) must match model output dimension \(3\)/ + ); + allowNativeLeaks(); // see `tasks/constructionFailure.test.ts` + }); + + it('surfaces a load failure', async () => { + await expect( + createClassifier({ ...config(), modelPath: '/models/absent.pte' }) + ).rejects.toThrow(/absent.pte/); + }); +}); + +describe('createClassifier — classify', () => { + beforeEach(registerBatched); + + it('returns every class, sorted by descending confidence', async () => { + const classifier = tracked(await createClassifier(config())); + + const results = await classifier.classify(imageBuffer(8, 8)); + + expect(results.map((r) => r.label)).toEqual(['bird', 'cat', 'dog']); + }); + + it('returns softmax probabilities that sum to one', async () => { + const classifier = tracked(await createClassifier(config())); + + const results = await classifier.classify(imageBuffer(8, 8)); + + const total = results.reduce((sum, r) => sum + r.confidence, 0); + expect(total).toBeCloseTo(1, 5); + // exp(2) / (exp(0) + exp(1) + exp(2)) + expect(results[0]!.confidence).toBeCloseTo(0.6652, 3); + }); + + it('truncates to topk', async () => { + const classifier = tracked(await createClassifier(config())); + + expect(await classifier.classify(imageBuffer(8, 8), { topk: 1 })).toEqual([ + { label: 'bird', confidence: expect.any(Number) }, + ]); + }); + + it('returns nothing for topk 0', async () => { + const classifier = tracked(await createClassifier(config())); + expect(await classifier.classify(imageBuffer(8, 8), { topk: 0 })).toEqual([]); + }); + + it('rejects a negative topk', async () => { + const classifier = tracked(await createClassifier(config())); + await expect(classifier.classify(imageBuffer(8, 8), { topk: -1 })).rejects.toThrow( + /non-negative/ + ); + }); + + it('accepts every supported input pixel format', async () => { + const classifier = tracked(await createClassifier(config())); + + for (const format of ['rgb', 'rgba', 'bgr', 'bgra', 'gray'] as const) { + expect(await classifier.classify(imageBuffer(6, 5, format), { topk: 1 })).toHaveLength(1); + } + }); + + it('resizes an input of any size onto the model input', async () => { + const classifier = tracked(await createClassifier(config())); + + for (const [width, height] of [ + [4, 4], + [16, 9], + [1, 1], + ]) { + expect(await classifier.classify(imageBuffer(width!, height!), { topk: 1 })).toHaveLength(1); + } + }); + + it('runs the exported forward method once per call', async () => { + const classifier = tracked(await createClassifier(config())); + + await classifier.classify(imageBuffer(8, 8)); + await classifier.classify(imageBuffer(8, 8)); + + expect(fakeJsi.executions()).toEqual([ + { path: MODEL_PATH, methodName: 'forward' }, + { path: MODEL_PATH, methodName: 'forward' }, + ]); + }); + + it('produces the same result synchronously and asynchronously', async () => { + const classifier = tracked(await createClassifier(config())); + const input = imageBuffer(8, 8); + + expect(classifier.classifyWorklet(input)).toEqual(await classifier.classify(input)); + }); +}); + +describe('createClassifier — lifetime', () => { + beforeEach(registerBatched); + + it('releases every native resource on dispose', async () => { + const classifier = tracked(await createClassifier(config())); + expect(fakeJsi.liveTensors()).toBeGreaterThan(0); + + classifier.dispose(); + + expect(fakeJsi.liveTensors()).toBe(0); + expect(fakeJsi.liveModels()).toEqual([]); + }); + + it('frees the scratch tensors allocated per call', async () => { + const classifier = tracked(await createClassifier(config())); + const afterConstruction = fakeJsi.liveTensors(); + + await classifier.classify(imageBuffer(32, 24)); + + expect(fakeJsi.liveTensors()).toBe(afterConstruction); + }); +}); diff --git a/packages/react-native-executorch/__tests__/tasks/constructionFailure.test.ts b/packages/react-native-executorch/__tests__/tasks/constructionFailure.test.ts new file mode 100644 index 0000000000..4d5d738e24 --- /dev/null +++ b/packages/react-native-executorch/__tests__/tasks/constructionFailure.test.ts @@ -0,0 +1,99 @@ +/** + * What a `create` factory leaves behind when it throws. + * + * Every factory follows the same shape: load the model (and, for some tasks, a + * tokenizer), validate its schema, pre-allocate the execution tensors, and only + * then hand back a `dispose`. If validation throws, the caller never receives a + * `dispose` — so anything already allocated is unreachable from JavaScript and + * stays alive in native memory for the process's lifetime. + * + * That is what these tests record. They are written as assertions on the + * current* behavior rather than on the desired behavior, so the day a + * factory starts cleaning up after itself they fail loudly and can be flipped, + * instead of quietly passing either way. + * + * The exposure is real: `useModel` re-runs its factory whenever the config + * changes, so an app pointed at a mismatched model leaks one native model per + * attempt. + */ +import { f32, method } from '../../src/core/schema'; +import { createClassifier } from '../../src/extensions/cv/tasks/classification'; +import { createImageEmbedder } from '../../src/extensions/cv/tasks/imageEmbedding'; +import { createObjectDetector } from '../../src/extensions/cv/tasks/objectDetection'; +import { createSemanticSegmenter } from '../../src/extensions/cv/tasks/semanticSegmentation'; +import { createStyleTransfer } from '../../src/extensions/cv/tasks/styleTransfer'; +import { fakeJsi } from '../support/fakeJsi'; +import { STRETCH_PREPROCESSING, exported } from '../support/fixtures'; +import { allowNativeLeaks } from '../support/setup'; + +const MODEL_PATH = '/models/mismatched.pte'; + +/** A schema no task pipeline declares: two inputs, three outputs, wrong ranks. */ +const MISMATCHED = exported(method('forward', [f32(9), f32(9)], [f32(9), f32(9), f32(9)])); + +const CV_OPTS = { + ...STRETCH_PREPROCESSING, + resizeMode: 'stretch', + outInterpolation: 'linear', + outNormalizeOpts: { alpha: 255, beta: 0 }, + labels: ['a'], + boxFormat: 'xyxy', + defaultIouThreshold: 0.5, + defaultConfidenceThreshold: 0.5, +} as const; + +const factories = { + classifier: () => createClassifier({ modelPath: MODEL_PATH, modelOpts: CV_OPTS }), + objectDetector: () => createObjectDetector({ modelPath: MODEL_PATH, modelOpts: CV_OPTS }), + semanticSegmenter: () => createSemanticSegmenter({ modelPath: MODEL_PATH, modelOpts: CV_OPTS }), + styleTransfer: () => createStyleTransfer({ modelPath: MODEL_PATH, modelOpts: CV_OPTS }), + imageEmbedder: () => createImageEmbedder({ modelPath: MODEL_PATH, modelOpts: CV_OPTS }), +}; + +describe('create — schema validation failure', () => { + beforeEach(() => { + fakeJsi.registerModel(MODEL_PATH, { schema: MISMATCHED }); + }); + + it.each(Object.entries(factories))( + 'create%s rejects with a message naming every variant it tried', + async (_name, factory) => { + await expect(factory()).rejects.toThrow(/doesn't match any of the provided variants/); + allowNativeLeaks(); + } + ); + + it.each(Object.entries(factories))( + 'create%s abandons the loaded native model (known leak)', + async (_name, factory) => { + await expect(factory()).rejects.toThrow(); + + expect(fakeJsi.liveModels()).toEqual([MODEL_PATH]); + allowNativeLeaks(); + } + ); + + it('leaks one model per failed attempt, the way a re-rendering hook would', async () => { + for (const path of ['/a.pte', '/b.pte', '/c.pte']) { + fakeJsi.registerModel(path, { schema: MISMATCHED }); + await expect(createClassifier({ modelPath: path, modelOpts: CV_OPTS })).rejects.toThrow(); + } + + expect(fakeJsi.liveModels()).toEqual(['/a.pte', '/b.pte', '/c.pte']); + allowNativeLeaks(); + }); +}); + +describe('create — load failure', () => { + it.each(Object.entries(factories))( + 'create%s leaves nothing allocated when the model itself cannot be loaded', + async (_name, factory) => { + // Nothing registered at MODEL_PATH, so `loadModel` throws before any + // allocation happens — the one failure path that is already clean. + await expect(factory()).rejects.toThrow(/mismatched.pte/); + + expect(fakeJsi.liveModels()).toEqual([]); + expect(fakeJsi.liveTensors()).toBe(0); + } + ); +}); diff --git a/packages/react-native-executorch/__tests__/tasks/embedding.test.ts b/packages/react-native-executorch/__tests__/tasks/embedding.test.ts new file mode 100644 index 0000000000..234c8728cc --- /dev/null +++ b/packages/react-native-executorch/__tests__/tasks/embedding.test.ts @@ -0,0 +1,199 @@ +import { RangeDim, constr, f32, i64, method } from '../../src/core/schema'; +import { createImageEmbedder } from '../../src/extensions/cv/tasks/imageEmbedding'; +import { createTextEmbedder } from '../../src/extensions/nlp/tasks/textEmbedding'; +import { fakeJsi } from '../support/fakeJsi'; +import { tracked } from '../support/lifetime'; +import type { FakeTensor } from '../support/fakeTensor'; +import { STRETCH_PREPROCESSING, exported, imageBuffer, writesOutputs } from '../support/fixtures'; +import { allowNativeLeaks } from '../support/setup'; + +const MODEL_PATH = '/models/embedder.pte'; +const TOKENIZER_PATH = '/models/tokenizer.json'; + +describe('createImageEmbedder', () => { + const config = { modelPath: MODEL_PATH, modelOpts: STRETCH_PREPROCESSING }; + + it('returns the raw embedding vector at the model output dimension', async () => { + fakeJsi.registerModel(MODEL_PATH, { + schema: exported(method('forward', [f32(1, 3, 4, 4)], [f32(1, 5)])), + execute: writesOutputs([0.1, 0.2, 0.3, 0.4, 0.5]), + }); + + const embedder = tracked(await createImageEmbedder(config)); + const embedding = await embedder.embed(imageBuffer(8, 8)); + + expect(embedding).toBeInstanceOf(Float32Array); + expect([...embedding].map((v) => Number(v.toFixed(3)))).toEqual([0.1, 0.2, 0.3, 0.4, 0.5]); + }); + + it('accepts the unbatched [3, H, W] -> [D] variant', async () => { + fakeJsi.registerModel(MODEL_PATH, { + schema: exported(method('forward', [f32(3, 4, 4)], [f32(5)])), + }); + + const embedder = tracked(await createImageEmbedder(config)); + expect(await embedder.embed(imageBuffer(4, 4))).toHaveLength(5); + }); + + it('does not normalize or pool — that is baked into the .pte', async () => { + fakeJsi.registerModel(MODEL_PATH, { + schema: exported(method('forward', [f32(1, 3, 4, 4)], [f32(1, 2)])), + execute: writesOutputs([3, 4]), + }); + + const embedder = tracked(await createImageEmbedder(config)); + expect([...(await embedder.embed(imageBuffer(4, 4)))]).toEqual([3, 4]); + }); + + it('releases everything on dispose', async () => { + fakeJsi.registerModel(MODEL_PATH, { + schema: exported(method('forward', [f32(1, 3, 4, 4)], [f32(1, 5)])), + }); + + const embedder = tracked(await createImageEmbedder(config)); + await embedder.embed(imageBuffer(9, 7)); + embedder.dispose(); + + expect(fakeJsi.liveTensors()).toBe(0); + expect(fakeJsi.liveModels()).toEqual([]); + }); +}); + +describe('createTextEmbedder', () => { + const VOCAB = ['', 'hello', 'world', 'query:', 'document:']; + const SEQUENCE = RangeDim(1, 8); + const EQUAL_LENGTHS = [ + constr.eq( + { paramSide: 'input', tensorIdx: 0, dimIdx: 1 }, + { paramSide: 'input', tensorIdx: 1, dimIdx: 1 } + ), + ]; + + /** Records the token ids and mask each `execute` received. */ + const recordedInputs: { ids: number[]; mask: number[] }[] = []; + + const register = () => { + recordedInputs.length = 0; + fakeJsi.registerModel(MODEL_PATH, { + schema: exported( + method('forward', [i64(1, SEQUENCE), i64(1, SEQUENCE)], [f32(1, 3)], EQUAL_LENGTHS) + ), + execute: (_methodName, inputs, out) => { + const [ids, mask] = inputs as [FakeTensor, FakeTensor]; + recordedInputs.push({ + ids: Array.from({ length: ids.numel }, (_, i) => ids.getElement(i)), + mask: Array.from({ length: mask.numel }, (_, i) => mask.getElement(i)), + }); + out[0]?.setElement(0, 1); + }, + }); + fakeJsi.registerTokenizer(TOKENIZER_PATH, { tokens: VOCAB }); + }; + + const config = { modelPath: MODEL_PATH, tokenizerPath: TOKENIZER_PATH }; + + it('validates the declared equality constraint between ids and mask', async () => { + register(); + const embedder = tracked(await createTextEmbedder(config)); + expect(embedder.embed).toBeInstanceOf(Function); + }); + + it('rejects a model that does not declare the equality constraint', async () => { + fakeJsi.registerModel(MODEL_PATH, { + schema: exported(method('forward', [i64(1, SEQUENCE), i64(1, SEQUENCE)], [f32(1, 3)])), + }); + fakeJsi.registerTokenizer(TOKENIZER_PATH, { tokens: VOCAB }); + + await expect(createTextEmbedder(config)).rejects.toThrow( + /doesn't match any of the provided variants/ + ); + allowNativeLeaks(); // see `tasks/constructionFailure.test.ts` + }); + + it('feeds tokens at their exact length with an all-ones attention mask', async () => { + register(); + const embedder = tracked(await createTextEmbedder(config)); + + await embedder.embed('hello world'); + + expect(recordedInputs).toEqual([{ ids: [1, 2], mask: [1, 1] }]); + }); + + it('truncates at the model maximum sequence length rather than failing', async () => { + register(); + const embedder = tracked(await createTextEmbedder(config)); + + await embedder.embed('hello world hello world hello world hello world hello world'); + + expect(recordedInputs[0]!.ids).toHaveLength(8); + }); + + it('prefixes the default prompt when one is configured', async () => { + register(); + const embedder = tracked(await createTextEmbedder({ ...config, defaultPrompt: 'query: ' })); + + await embedder.embed('hello'); + + expect(recordedInputs[0]!.ids).toEqual([3, 1]); + }); + + it('lets a per-call prompt override the default', async () => { + register(); + const embedder = tracked(await createTextEmbedder({ ...config, defaultPrompt: 'query: ' })); + + await embedder.embed('hello', 'document: '); + + expect(recordedInputs[0]!.ids).toEqual([4, 1]); + }); + + it('rejects input that tokenizes to nothing', async () => { + register(); + fakeJsi.registerTokenizer(TOKENIZER_PATH, { tokens: [] }); + const embedder = tracked(await createTextEmbedder(config)); + + await expect(embedder.embed('')).rejects.toThrow(/zero tokens/); + }); + + it('frees the per-call token tensors even when execute throws', async () => { + register(); + fakeJsi.registerModel(MODEL_PATH, { + schema: exported( + method('forward', [i64(1, SEQUENCE), i64(1, SEQUENCE)], [f32(1, 3)], EQUAL_LENGTHS) + ), + execute: () => { + throw new Error('backend failure'); + }, + }); + + const embedder = tracked(await createTextEmbedder(config)); + const before = fakeJsi.liveTensors(); + + await expect(embedder.embed('hello')).rejects.toThrow('backend failure'); + + expect(fakeJsi.liveTensors()).toBe(before); + }); + + it('releases the model and the tokenizer on dispose', async () => { + register(); + const embedder = tracked(await createTextEmbedder(config)); + await embedder.embed('hello'); + + embedder.dispose(); + + expect(fakeJsi.liveModels()).toEqual([]); + expect(fakeJsi.liveTokenizers()).toEqual([]); + expect(fakeJsi.liveTensors()).toBe(0); + }); + + it('accepts the unbatched [D] output variant', async () => { + fakeJsi.registerModel(MODEL_PATH, { + schema: exported( + method('forward', [i64(1, SEQUENCE), i64(1, SEQUENCE)], [f32(3)], EQUAL_LENGTHS) + ), + }); + fakeJsi.registerTokenizer(TOKENIZER_PATH, { tokens: VOCAB }); + + const embedder = tracked(await createTextEmbedder(config)); + expect(await embedder.embed('hello')).toHaveLength(3); + }); +}); diff --git a/packages/react-native-executorch/__tests__/tasks/objectDetection.test.ts b/packages/react-native-executorch/__tests__/tasks/objectDetection.test.ts new file mode 100644 index 0000000000..ee711b213b --- /dev/null +++ b/packages/react-native-executorch/__tests__/tasks/objectDetection.test.ts @@ -0,0 +1,212 @@ +import { f32, method } from '../../src/core/schema'; +import { createObjectDetector } from '../../src/extensions/cv/tasks/objectDetection'; +import { fakeJsi } from '../support/fakeJsi'; +import { tracked } from '../support/lifetime'; +import { STRETCH_PREPROCESSING, exported, imageBuffer, writesOutputs } from '../support/fixtures'; +import { allowNativeLeaks } from '../support/setup'; + +const MODEL_PATH = '/models/detector.pte'; +const LABELS = ['person', 'car', 'dog'] as const; + +// Model geometry: 16x16 input, three candidate boxes in xyxy. +const MODEL_SIZE = 16; + +/** + * Boxes 0 and 1 overlap (IoU 49/79 = 0.62); box 2 is disjoint. Scores are + * chosen so box 1 wins its overlap group and every box clears the default + * confidence threshold. + */ +const BOXES = [ + 0, + 0, + 8, + 8, // box 0 + 1, + 1, + 9, + 9, // box 1 + 12, + 12, + 16, + 16, // box 2 +]; +const SCORES = [0.6, 0.9, 0.7]; +const CLASSES = [0, 1, 2]; + +const options = { + ...STRETCH_PREPROCESSING, + resizeMode: 'stretch', + labels: LABELS, + boxFormat: 'xyxy', + defaultIouThreshold: 0.5, + defaultConfidenceThreshold: 0.5, +} as const; + +const register = (classes: number[] = CLASSES) => { + fakeJsi.registerModel(MODEL_PATH, { + schema: exported( + method('forward', [f32(1, 3, MODEL_SIZE, MODEL_SIZE)], [f32(3, 4), f32(3), f32(3)]) + ), + execute: writesOutputs(BOXES, SCORES, classes), + }); +}; + +const config = { modelPath: MODEL_PATH, modelOpts: options }; + +describe('createObjectDetector — detection', () => { + beforeEach(() => register()); + + it('suppresses overlapping boxes and keeps the highest scoring one', async () => { + const detector = tracked(await createObjectDetector(config)); + + const detections = await detector.detectObjects(imageBuffer(MODEL_SIZE, MODEL_SIZE)); + + expect(detections.map((d) => d.label)).toEqual(['car', 'dog']); + }); + + it('maps class indices to their labels and keeps the confidence', async () => { + const detector = tracked(await createObjectDetector(config)); + + const [best] = await detector.detectObjects(imageBuffer(MODEL_SIZE, MODEL_SIZE)); + + expect(best!.label).toBe('car'); + expect(best!.confidence).toBeCloseTo(0.9, 5); + }); + + it('returns boxes in the configured format', async () => { + const detector = tracked(await createObjectDetector(config)); + + const [best] = await detector.detectObjects(imageBuffer(MODEL_SIZE, MODEL_SIZE)); + + expect(best!.box).toEqual({ format: 'xyxy', xmin: 1, ymin: 1, xmax: 9, ymax: 9 }); + }); + + it('scales boxes from the model input back to the original image size', async () => { + const detector = tracked(await createObjectDetector(config)); + + // The image is twice the model input, so every coordinate doubles. + const [best] = await detector.detectObjects(imageBuffer(MODEL_SIZE * 2, MODEL_SIZE * 2)); + + expect(best!.box).toEqual({ format: 'xyxy', xmin: 2, ymin: 2, xmax: 18, ymax: 18 }); + }); + + it('drops candidates below the default confidence threshold', async () => { + const detector = tracked( + await createObjectDetector({ + ...config, + modelOpts: { ...options, defaultConfidenceThreshold: 0.8 }, + }) + ); + + const detections = await detector.detectObjects(imageBuffer(MODEL_SIZE, MODEL_SIZE)); + + expect(detections.map((d) => d.label)).toEqual(['car']); + }); + + it('lets a per-call confidence threshold override the default', async () => { + const detector = tracked(await createObjectDetector(config)); + + const detections = await detector.detectObjects(imageBuffer(MODEL_SIZE, MODEL_SIZE), { + confidenceThreshold: 0.95, + }); + + expect(detections).toEqual([]); + }); + + it('lets a per-call IoU threshold override the default', async () => { + const detector = tracked(await createObjectDetector(config)); + + // A threshold above the pair's IoU stops the suppression, so the + // lower-scoring overlapping box survives. + const detections = await detector.detectObjects(imageBuffer(MODEL_SIZE, MODEL_SIZE), { + iouThreshold: 0.9, + }); + + expect(detections.map((d) => d.label)).toEqual(['car', 'dog', 'person']); + }); + + it('throws when the model predicts a class outside the labels array', async () => { + register([0, 7, 2]); + const detector = tracked(await createObjectDetector(config)); + + await expect(detector.detectObjects(imageBuffer(MODEL_SIZE, MODEL_SIZE))).rejects.toThrow( + /class index 7 is out of bounds.*size 3/s + ); + }); + + it('produces the same result synchronously and asynchronously', async () => { + const detector = tracked(await createObjectDetector(config)); + const input = imageBuffer(MODEL_SIZE, MODEL_SIZE); + + expect(detector.detectObjectsWorklet(input)).toEqual(await detector.detectObjects(input)); + }); +}); + +describe('createObjectDetector — letterboxing', () => { + beforeEach(() => register()); + + it('undoes the letterbox padding when scaling boxes back', async () => { + const detector = tracked( + await createObjectDetector({ + ...config, + modelOpts: { ...options, resizeMode: 'letterbox' }, + }) + ); + + // A 32x16 image letterboxed into a 16x16 input: scale 0.5, with 4px of + // padding above and below. A box at y=1 in model space is therefore + // above the image content and maps to a negative y. + const [best] = await detector.detectObjects(imageBuffer(32, 16)); + + expect(best!.box).toMatchObject({ format: 'xyxy', xmin: 2, xmax: 18 }); + expect((best!.box as { ymin: number }).ymin).toBeCloseTo(-6, 5); + }); +}); + +describe('createObjectDetector — model acceptance', () => { + it('accepts the unbatched [3, H, W] variant', async () => { + fakeJsi.registerModel(MODEL_PATH, { + schema: exported( + method('forward', [f32(3, MODEL_SIZE, MODEL_SIZE)], [f32(3, 4), f32(3), f32(3)]) + ), + execute: writesOutputs(BOXES, SCORES, CLASSES), + }); + + const detector = tracked(await createObjectDetector(config)); + expect(await detector.detectObjects(imageBuffer(MODEL_SIZE, MODEL_SIZE))).toHaveLength(2); + }); + + it('rejects a model with the wrong number of outputs', async () => { + fakeJsi.registerModel(MODEL_PATH, { + schema: exported(method('forward', [f32(1, 3, MODEL_SIZE, MODEL_SIZE)], [f32(3, 4), f32(3)])), + }); + + await expect(createObjectDetector(config)).rejects.toThrow(/Output count mismatch/); + allowNativeLeaks(); // see `tasks/constructionFailure.test.ts` + }); + + it('requires boxes, scores and classes to agree on the candidate count', async () => { + fakeJsi.registerModel(MODEL_PATH, { + schema: exported( + method('forward', [f32(1, 3, MODEL_SIZE, MODEL_SIZE)], [f32(3, 4), f32(3), f32(5)]) + ), + }); + + await expect(createObjectDetector(config)).rejects.toThrow(/inconsistent bindings/); + allowNativeLeaks(); // see `tasks/constructionFailure.test.ts` + }); +}); + +describe('createObjectDetector — lifetime', () => { + beforeEach(() => register()); + + it('releases every native resource on dispose', async () => { + const detector = tracked(await createObjectDetector(config)); + await detector.detectObjects(imageBuffer(40, 30)); + + detector.dispose(); + + expect(fakeJsi.liveTensors()).toBe(0); + expect(fakeJsi.liveModels()).toEqual([]); + }); +}); diff --git a/packages/react-native-executorch/__tests__/tasks/remainingTasks.test.ts b/packages/react-native-executorch/__tests__/tasks/remainingTasks.test.ts new file mode 100644 index 0000000000..3f2713418f --- /dev/null +++ b/packages/react-native-executorch/__tests__/tasks/remainingTasks.test.ts @@ -0,0 +1,347 @@ +/** + * Schema acceptance and disposal for the pipelines that are not exercised + * end to end elsewhere. + * + * Keypoint detection, instance segmentation, VAD, Whisper and SDXS each carry + * a long stateful `worklet` — a decode loop, a rolling audio window, a + * diffusion step — whose behavior depends on real model weights rather than on + * the TypeScript around them. Faking a whole Whisper decode would mostly test + * the fixture. + * + * What is worth pinning down here is the part that is pure contract: the exact + * signature each pipeline accepts, that a mismatch is rejected rather than + * crashing later inside `execute`, and that `dispose()` releases every native + * handle the pipeline took — including the nested ones (Whisper owns a + * tokenizer and a whole VAD pipeline). + */ +import { RangeDim, f32, i64, method } from '../../src/core/schema'; +import { createInstanceSegmenter } from '../../src/extensions/cv/tasks/instanceSegmentation'; +import { createKeypointDetector } from '../../src/extensions/cv/tasks/keypointDetection'; +import { createSdxsTextToImage } from '../../src/extensions/cv/tasks/sdxsTextToImage'; +import { createFsmnVoiceActivityDetector } from '../../src/extensions/speech/tasks/fsmnVoiceActivityDetection'; +import { createWhisperSpeechToText } from '../../src/extensions/speech/tasks/whisperSpeechToText'; +import { fakeJsi } from '../support/fakeJsi'; +import { STRETCH_PREPROCESSING, exported } from '../support/fixtures'; +import { allowNativeLeaks } from '../support/setup'; + +const MODEL_PATH = '/models/task.pte'; +const TOKENIZER_PATH = '/models/tokenizer.json'; +const VAD_PATH = '/models/vad.pte'; + +// ============================================================================ +// Keypoint detection +// ============================================================================ + +describe('createKeypointDetector', () => { + const LANDMARKS = ['nose', 'left_eye', 'right_eye'] as const; + const config = { + modelPath: MODEL_PATH, + modelOpts: { + ...STRETCH_PREPROCESSING, + resizeMode: 'stretch', + boxFormat: 'xyxy', + landmarks: LANDMARKS, + defaultIouThreshold: 0.5, + defaultConfidenceThreshold: 0.5, + }, + } as const; + + it('accepts a model whose keypoint output matches the landmark count', async () => { + fakeJsi.registerModel(MODEL_PATH, { + schema: exported( + method('forward', [f32(1, 3, 8, 8)], [f32(2, 4), f32(2), f32(2, LANDMARKS.length, 3)]) + ), + }); + + const detector = await createKeypointDetector(config); + expect(detector.detectKeypoints).toBeInstanceOf(Function); + + detector.dispose(); + expect(fakeJsi.liveTensors()).toBe(0); + expect(fakeJsi.liveModels()).toEqual([]); + }); + + it('rejects a model whose keypoint output has a different landmark count', async () => { + fakeJsi.registerModel(MODEL_PATH, { + schema: exported(method('forward', [f32(1, 3, 8, 8)], [f32(2, 4), f32(2), f32(2, 17, 3)])), + }); + + await expect(createKeypointDetector(config)).rejects.toThrow(/Constant dimension mismatch/); + allowNativeLeaks(); // see `tasks/constructionFailure.test.ts` + }); + + it('has no unbatched variant', async () => { + fakeJsi.registerModel(MODEL_PATH, { + schema: exported( + method('forward', [f32(3, 8, 8)], [f32(2, 4), f32(2), f32(2, LANDMARKS.length, 3)]) + ), + }); + + await expect(createKeypointDetector(config)).rejects.toThrow(/Rank mismatch/); + allowNativeLeaks(); // see `tasks/constructionFailure.test.ts` + }); +}); + +// ============================================================================ +// Instance segmentation +// ============================================================================ + +describe('createInstanceSegmenter', () => { + const config = { + modelPath: MODEL_PATH, + modelOpts: { + ...STRETCH_PREPROCESSING, + resizeMode: 'stretch', + labels: ['person', 'car'], + boxFormat: 'xyxy', + defaultIouThreshold: 0.5, + defaultMaskThreshold: 0.5, + defaultConfidenceThreshold: 0.5, + }, + } as const; + + const outputs = [f32(2, 4), f32(2), f32(2), f32(2, 6, 6)]; + + it.each([ + ['batched', [f32(1, 3, 8, 8)]], + ['unbatched', [f32(3, 8, 8)]], + ])('accepts the %s variant and disposes cleanly', async (_variant, inputs) => { + fakeJsi.registerModel(MODEL_PATH, { + schema: exported(method('forward', inputs, outputs)), + }); + + const segmenter = await createInstanceSegmenter(config); + expect(segmenter.segmentInstances).toBeInstanceOf(Function); + + segmenter.dispose(); + expect(fakeJsi.liveTensors()).toBe(0); + expect(fakeJsi.liveModels()).toEqual([]); + }); + + it('allows the mask resolution to differ from the input resolution', async () => { + fakeJsi.registerModel(MODEL_PATH, { + schema: exported( + method('forward', [f32(1, 3, 32, 32)], [f32(2, 4), f32(2), f32(2), f32(2, 6, 10)]) + ), + }); + + const segmenter = await createInstanceSegmenter(config); + expect(segmenter.segmentInstances).toBeInstanceOf(Function); + segmenter.dispose(); + }); + + it('requires every output to agree on the instance count', async () => { + fakeJsi.registerModel(MODEL_PATH, { + schema: exported( + method('forward', [f32(1, 3, 8, 8)], [f32(2, 4), f32(2), f32(2), f32(3, 6, 6)]) + ), + }); + + await expect(createInstanceSegmenter(config)).rejects.toThrow(/inconsistent bindings/); + allowNativeLeaks(); // see `tasks/constructionFailure.test.ts` + }); +}); + +// ============================================================================ +// Voice activity detection +// ============================================================================ + +// The exported spec is concrete: `frames` is a real range in both the input +// and the output, which is what the pipeline's `Dyn('frames')` binds to. +const FRAMES = RangeDim(1, 500); +const VAD_SCHEMA = exported(method('forward', [f32(FRAMES, 400)], [f32(1, FRAMES, 2)])); + +const VAD_CONFIG = { + modelPath: VAD_PATH, + defaultOptions: { + speechThreshold: 0.5, + minSpeechDurationMs: 250, + minSilenceDurationMs: 100, + speechPadMs: 30, + mergeGapMs: 100, + }, +} as const; + +describe('createFsmnVoiceActivityDetector', () => { + it('accepts a model with a dynamic frame count and disposes cleanly', async () => { + fakeJsi.registerModel(VAD_PATH, { schema: VAD_SCHEMA }); + + const vad = await createFsmnVoiceActivityDetector(VAD_CONFIG); + expect(vad.detectVoice).toBeInstanceOf(Function); + + vad.dispose(); + expect(fakeJsi.liveTensors()).toBe(0); + expect(fakeJsi.liveModels()).toEqual([]); + }); + + it('rejects a model whose frame count is static', async () => { + fakeJsi.registerModel(VAD_PATH, { + schema: exported(method('forward', [f32(100, 400)], [f32(1, 100, 2)])), + }); + + await expect(createFsmnVoiceActivityDetector(VAD_CONFIG)).rejects.toThrow( + /Cannot match symbolic 'dynamic' with concrete 'constant'/ + ); + allowNativeLeaks(); // see `tasks/constructionFailure.test.ts` + }); + + it('requires the input and output frame dimensions to share a domain', async () => { + fakeJsi.registerModel(VAD_PATH, { + schema: exported( + method('forward', [f32(RangeDim(1, 500), 400)], [f32(1, RangeDim(1, 250), 2)]) + ), + }); + + await expect(createFsmnVoiceActivityDetector(VAD_CONFIG)).rejects.toThrow( + /inconsistent bindings/ + ); + allowNativeLeaks(); // see `tasks/constructionFailure.test.ts` + }); + + it('returns no segments for a waveform shorter than one analysis frame', async () => { + fakeJsi.registerModel(VAD_PATH, { schema: VAD_SCHEMA }); + + const vad = await createFsmnVoiceActivityDetector(VAD_CONFIG); + expect(await vad.detectVoice(new Float32Array(100))).toEqual([]); + vad.dispose(); + }); +}); + +// ============================================================================ +// Whisper speech to text +// ============================================================================ + +describe('createWhisperSpeechToText', () => { + // Whisper pads internally, so the exported encode input is a dynamic range. + const AUDIO_SAMPLES = RangeDim(1, 480000); + const config = { + modelPath: MODEL_PATH, + tokenizerPath: TOKENIZER_PATH, + supportedLanguages: ['en'], + vadModel: VAD_CONFIG, + } as const; + + const register = () => { + fakeJsi.registerModel(MODEL_PATH, { + schema: exported({ + ...method('encode', [f32(AUDIO_SAMPLES)], [f32(1, 1500, 384)]), + ...method('decode', [i64(1, 1), i64(1), f32(1, 1500, 384)], [f32(1, 1, 51865)]), + }), + }); + fakeJsi.registerModel(VAD_PATH, { schema: VAD_SCHEMA }); + fakeJsi.registerTokenizer(TOKENIZER_PATH, { + tokens: ['<|endoftext|>', 'hello', 'world'], + specialIds: [0], + }); + }; + + it('accepts a model exporting both encode and decode', async () => { + register(); + + const stt = await createWhisperSpeechToText(config); + expect(stt.transcribe).toBeInstanceOf(Function); + expect(stt.stream).toBeInstanceOf(Function); + + stt.dispose(); + }); + + it('releases the model, the tokenizer and the nested VAD pipeline on dispose', async () => { + register(); + + const stt = await createWhisperSpeechToText(config); + expect(fakeJsi.liveModels()).toEqual([MODEL_PATH, VAD_PATH]); + + stt.dispose(); + + expect(fakeJsi.liveModels()).toEqual([]); + expect(fakeJsi.liveTokenizers()).toEqual([]); + expect(fakeJsi.liveTensors()).toBe(0); + }); + + it('rejects a model missing the decode method', async () => { + register(); + fakeJsi.registerModel(MODEL_PATH, { + schema: exported(method('encode', [f32(AUDIO_SAMPLES)], [f32(1, 1500, 384)])), + }); + + await expect(createWhisperSpeechToText(config)).rejects.toThrow( + /Method 'decode' not found in exported model spec/ + ); + allowNativeLeaks(); // see `tasks/constructionFailure.test.ts` + }); + + it('requires encode and decode to agree on the encoder state shape', async () => { + register(); + fakeJsi.registerModel(MODEL_PATH, { + schema: exported({ + ...method('encode', [f32(AUDIO_SAMPLES)], [f32(1, 1500, 384)]), + ...method('decode', [i64(1, 1), i64(1), f32(1, 1500, 512)], [f32(1, 1, 51865)]), + }), + }); + + await expect(createWhisperSpeechToText(config)).rejects.toThrow(/inconsistent bindings/); + allowNativeLeaks(); // see `tasks/constructionFailure.test.ts` + }); + + it('fails when the tokenizer has no end-of-text token', async () => { + register(); + fakeJsi.registerTokenizer(TOKENIZER_PATH, { tokens: ['hello'] }); + + await expect(createWhisperSpeechToText(config)).rejects.toThrow(/<\|endoftext\|>/); + allowNativeLeaks(); // see `tasks/constructionFailure.test.ts` + }); +}); + +// ============================================================================ +// SDXS text to image +// ============================================================================ + +describe('createSdxsTextToImage', () => { + const config = { modelPath: MODEL_PATH, tokenizerPath: TOKENIZER_PATH }; + + const SDXS_SCHEMA = exported({ + ...method('encode', [i64(1, 77)], [f32(1, 77, 768)]), + ...method('denoise', [f32(1, 4, 64, 64), i64(1), f32(1, 77, 768)], [f32(1, 4, 64, 64)]), + ...method('decode', [f32(1, 4, 64, 64)], [f32(1, 3, 512, 512)]), + }); + + it('accepts the three-method SDXS contract and disposes cleanly', async () => { + fakeJsi.registerModel(MODEL_PATH, { schema: SDXS_SCHEMA }); + fakeJsi.registerTokenizer(TOKENIZER_PATH, { tokens: ['a', 'cat'] }); + + const tti = await createSdxsTextToImage(config); + expect(tti.generate).toBeInstanceOf(Function); + + tti.dispose(); + expect(fakeJsi.liveTensors()).toBe(0); + expect(fakeJsi.liveModels()).toEqual([]); + expect(fakeJsi.liveTokenizers()).toEqual([]); + }); + + it('rejects a model that is missing the denoise method', async () => { + fakeJsi.registerModel(MODEL_PATH, { + schema: exported({ + ...method('encode', [i64(1, 77)], [f32(1, 77, 768)]), + ...method('decode', [f32(1, 4, 64, 64)], [f32(1, 3, 512, 512)]), + }), + }); + fakeJsi.registerTokenizer(TOKENIZER_PATH, { tokens: ['a'] }); + + await expect(createSdxsTextToImage(config)).rejects.toThrow(/Method 'denoise' not found/); + allowNativeLeaks(); // see `tasks/constructionFailure.test.ts` + }); + + it('rejects a text encoder with a different hidden size', async () => { + fakeJsi.registerModel(MODEL_PATH, { + schema: exported({ + ...method('encode', [i64(1, 77)], [f32(1, 77, 512)]), + ...method('denoise', [f32(1, 4, 64, 64), i64(1), f32(1, 77, 512)], [f32(1, 4, 64, 64)]), + ...method('decode', [f32(1, 4, 64, 64)], [f32(1, 3, 512, 512)]), + }), + }); + fakeJsi.registerTokenizer(TOKENIZER_PATH, { tokens: ['a'] }); + + await expect(createSdxsTextToImage(config)).rejects.toThrow(/Constant dimension mismatch/); + allowNativeLeaks(); // see `tasks/constructionFailure.test.ts` + }); +}); diff --git a/packages/react-native-executorch/__tests__/tasks/semanticSegmentation.test.ts b/packages/react-native-executorch/__tests__/tasks/semanticSegmentation.test.ts new file mode 100644 index 0000000000..5e066e2e41 --- /dev/null +++ b/packages/react-native-executorch/__tests__/tasks/semanticSegmentation.test.ts @@ -0,0 +1,179 @@ +import { f32, method } from '../../src/core/schema'; +import { createSemanticSegmenter } from '../../src/extensions/cv/tasks/semanticSegmentation'; +import { fakeJsi } from '../support/fakeJsi'; +import { tracked } from '../support/lifetime'; +import { STRETCH_PREPROCESSING, exported, imageBuffer, writesOutputs } from '../support/fixtures'; +import { allowNativeLeaks } from '../support/setup'; + +const MODEL_PATH = '/models/segmenter.pte'; +const LABELS = ['background', 'person', 'cat'] as const; +const SIZE = 2; + +const options = { + ...STRETCH_PREPROCESSING, + resizeMode: 'stretch', + outInterpolation: 'nearest', + labels: LABELS, +} as const; + +const config = { modelPath: MODEL_PATH, modelOpts: options }; + +/** + * Per-class logits for a 2x2 image in CHW order. Class 1 wins the top-left + * pixel, class 2 the next, and class 0 the bottom row. + */ +const LOGITS = [ + // class 0 + 0, 0, 9, 9, + // class 1 + 9, 0, 0, 0, + // class 2 + 0, 9, 0, 0, +]; + +const registerMultiClass = () => { + fakeJsi.registerModel(MODEL_PATH, { + schema: exported( + method('forward', [f32(1, 3, SIZE, SIZE)], [f32(1, LABELS.length, SIZE, SIZE)]) + ), + execute: writesOutputs(LOGITS), + }); +}; + +/** + * Reads the RGBA pixel at (x, y) from a segmentation result buffer. + */ +const pixelAt = (data: Uint8Array, width: number, x: number, y: number): number[] => [ + ...data.slice((y * width + x) * 4, (y * width + x) * 4 + 4), +]; + +describe('createSemanticSegmenter — multi-class models', () => { + beforeEach(registerMultiClass); + + it('rejects a labels array that does not match the class dimension', async () => { + await expect( + createSemanticSegmenter({ ...config, modelOpts: { ...options, labels: ['only-one'] } }) + ).rejects.toThrow(/Model outputs 3 classes, but 1 labels were provided/); + allowNativeLeaks(); // see `tasks/constructionFailure.test.ts` + }); + + it('returns an RGBA mask at the input image resolution', async () => { + const segmenter = tracked(await createSemanticSegmenter(config)); + + const { buffer } = await segmenter.segment(imageBuffer(8, 6)); + + expect(buffer).toMatchObject({ width: 8, height: 6, format: 'rgba', layout: 'hwc' }); + expect(buffer.data).toHaveLength(8 * 6 * 4); + }); + + it('colors each pixel by its argmax class', async () => { + const segmenter = tracked(await createSemanticSegmenter(config)); + + const { buffer, colormap } = await segmenter.segment(imageBuffer(SIZE, SIZE)); + + expect(pixelAt(buffer.data, SIZE, 0, 0)).toEqual(colormap!.person); + expect(pixelAt(buffer.data, SIZE, 1, 0)).toEqual(colormap!.cat); + expect(pixelAt(buffer.data, SIZE, 0, 1)).toEqual(colormap!.background); + }); + + it('generates a default colormap with a transparent first class', async () => { + const segmenter = tracked(await createSemanticSegmenter(config)); + + const { colormap } = await segmenter.segment(imageBuffer(SIZE, SIZE)); + + expect(Object.keys(colormap!).sort()).toEqual([...LABELS].sort()); + expect(colormap!.background).toEqual([0, 0, 0, 0]); + expect(colormap!.person).not.toEqual(colormap!.cat); + expect(colormap!.person[3]).toBe(255); + }); + + it('uses an explicit colormap when one is given', async () => { + const segmenter = tracked(await createSemanticSegmenter(config)); + + const { buffer, colormap } = await segmenter.segment(imageBuffer(SIZE, SIZE), { + person: [10, 20, 30, 255], + }); + + expect(colormap!.person).toEqual([10, 20, 30, 255]); + expect(pixelAt(buffer.data, SIZE, 0, 0)).toEqual([10, 20, 30, 255]); + }); + + it('renders labels omitted from a partial colormap as transparent', async () => { + const segmenter = tracked(await createSemanticSegmenter(config)); + + const { buffer, colormap } = await segmenter.segment(imageBuffer(SIZE, SIZE), { + person: [10, 20, 30, 255], + }); + + expect(colormap!.cat).toEqual([0, 0, 0, 0]); + expect(pixelAt(buffer.data, SIZE, 1, 0)).toEqual([0, 0, 0, 0]); + }); +}); + +describe('createSemanticSegmenter — single-class models', () => { + beforeEach(() => { + fakeJsi.registerModel(MODEL_PATH, { + schema: exported(method('forward', [f32(1, 3, SIZE, SIZE)], [f32(1, 1, SIZE, SIZE)])), + // Logits spanning the sigmoid range: strongly negative to strongly positive. + execute: writesOutputs([-10, 0, 10, 10]), + }); + }); + + it('does not require the labels array to match', async () => { + const segmenter = tracked( + await createSemanticSegmenter({ ...config, modelOpts: { ...options, labels: ['fg'] } }) + ); + expect(segmenter.segment).toBeInstanceOf(Function); + }); + + it('returns no colormap — the mask is a grayscale probability', async () => { + const segmenter = tracked(await createSemanticSegmenter(config)); + + const { colormap } = await segmenter.segment(imageBuffer(SIZE, SIZE)); + + expect(colormap).toBeUndefined(); + }); + + it('maps the sigmoid probability onto the 0-255 grayscale range', async () => { + const segmenter = tracked(await createSemanticSegmenter(config)); + + const { buffer } = await segmenter.segment(imageBuffer(SIZE, SIZE)); + + // sigmoid(-10) ~ 0, sigmoid(0) = 0.5, sigmoid(10) ~ 1. + expect(pixelAt(buffer.data, SIZE, 0, 0).slice(0, 3)).toEqual([0, 0, 0]); + expect(pixelAt(buffer.data, SIZE, 1, 0).slice(0, 3)).toEqual([128, 128, 128]); + expect(pixelAt(buffer.data, SIZE, 0, 1).slice(0, 3)).toEqual([255, 255, 255]); + }); + + it('writes an opaque alpha channel', async () => { + const segmenter = tracked(await createSemanticSegmenter(config)); + + const { buffer } = await segmenter.segment(imageBuffer(SIZE, SIZE)); + + expect(pixelAt(buffer.data, SIZE, 0, 0)[3]).toBe(255); + }); +}); + +describe('createSemanticSegmenter — lifetime', () => { + beforeEach(registerMultiClass); + + it('releases every native resource on dispose', async () => { + const segmenter = await createSemanticSegmenter(config); + await segmenter.segment(imageBuffer(12, 9)); + + segmenter.dispose(); + + expect(fakeJsi.liveTensors()).toBe(0); + expect(fakeJsi.liveModels()).toEqual([]); + }); + + it('frees the per-call resize tensor', async () => { + const segmenter = tracked(await createSemanticSegmenter(config)); + const afterConstruction = fakeJsi.liveTensors(); + + await segmenter.segment(imageBuffer(12, 9)); + await segmenter.segment(imageBuffer(20, 20)); + + expect(fakeJsi.liveTensors()).toBe(afterConstruction); + }); +}); diff --git a/packages/react-native-executorch/__tests__/tasks/styleTransfer.test.ts b/packages/react-native-executorch/__tests__/tasks/styleTransfer.test.ts new file mode 100644 index 0000000000..606f6612ed --- /dev/null +++ b/packages/react-native-executorch/__tests__/tasks/styleTransfer.test.ts @@ -0,0 +1,108 @@ +import { f32, method } from '../../src/core/schema'; +import { createStyleTransfer } from '../../src/extensions/cv/tasks/styleTransfer'; +import { fakeJsi } from '../support/fakeJsi'; +import { tracked } from '../support/lifetime'; +import { + STRETCH_PREPROCESSING, + copiesInputToOutput, + exported, + imageBuffer, +} from '../support/fixtures'; +import { allowNativeLeaks } from '../support/setup'; + +const MODEL_PATH = '/models/style.pte'; +const SIZE = 4; + +const options = { + ...STRETCH_PREPROCESSING, + resizeMode: 'stretch', + outNormalizeOpts: { alpha: 255, beta: 0 }, + outInterpolation: 'nearest', +} as const; + +const config = { modelPath: MODEL_PATH, modelOpts: options }; + +/** An identity model: whatever preprocessing produced comes straight back. */ +const registerIdentity = () => { + fakeJsi.registerModel(MODEL_PATH, { + schema: exported(method('forward', [f32(1, 3, SIZE, SIZE)], [f32(1, 3, SIZE, SIZE)])), + execute: copiesInputToOutput(), + }); +}; + +describe('createStyleTransfer', () => { + beforeEach(registerIdentity); + + it('returns an opaque RGBA buffer at the input resolution', async () => { + const transfer = tracked(await createStyleTransfer(config)); + + const output = await transfer.transferStyle(imageBuffer(10, 6)); + + expect(output).toMatchObject({ width: 10, height: 6, format: 'rgba', layout: 'hwc' }); + expect(output.data).toHaveLength(10 * 6 * 4); + expect([...output.data].filter((_, i) => i % 4 === 3).every((a) => a === 255)).toBe(true); + }); + + it('round-trips pixel values through normalization and back', async () => { + // The preprocessor divides by 255 and `outNormalizeOpts` multiplies by 255, + // so an identity model must reproduce the input pixels exactly. + const transfer = tracked(await createStyleTransfer(config)); + const input = imageBuffer(SIZE, SIZE); + + const output = await transfer.transferStyle(input); + + const rgbOf = (data: Uint8Array, stride: number, index: number) => [ + ...data.slice(index * stride, index * stride + 3), + ]; + for (let pixel = 0; pixel < SIZE * SIZE; pixel++) { + expect(rgbOf(output.data, 4, pixel)).toEqual(rgbOf(input.data, 3, pixel)); + } + }); + + it('accepts the unbatched [3, H, W] variant', async () => { + fakeJsi.registerModel(MODEL_PATH, { + schema: exported(method('forward', [f32(3, SIZE, SIZE)], [f32(3, SIZE, SIZE)])), + execute: copiesInputToOutput(), + }); + + const transfer = tracked(await createStyleTransfer(config)); + expect((await transfer.transferStyle(imageBuffer(SIZE, SIZE))).data).toHaveLength( + SIZE * SIZE * 4 + ); + }); + + it('rejects a model whose output shape differs from its input shape', async () => { + fakeJsi.registerModel(MODEL_PATH, { + schema: exported(method('forward', [f32(1, 3, SIZE, SIZE)], [f32(1, 3, SIZE * 2, SIZE)])), + }); + + await expect(createStyleTransfer(config)).rejects.toThrow(/inconsistent bindings/); + allowNativeLeaks(); // see `tasks/constructionFailure.test.ts` + }); + + it('produces the same result synchronously and asynchronously', async () => { + const transfer = tracked(await createStyleTransfer(config)); + const input = imageBuffer(SIZE, SIZE); + + expect(transfer.transferStyleWorklet(input)).toEqual(await transfer.transferStyle(input)); + }); + + it('releases every native resource on dispose', async () => { + const transfer = await createStyleTransfer(config); + await transfer.transferStyle(imageBuffer(20, 15)); + + transfer.dispose(); + + expect(fakeJsi.liveTensors()).toBe(0); + expect(fakeJsi.liveModels()).toEqual([]); + }); + + it('frees the per-call resize tensor across repeated calls', async () => { + const transfer = tracked(await createStyleTransfer(config)); + const afterConstruction = fakeJsi.liveTensors(); + + for (const size of [8, 16, 32]) await transfer.transferStyle(imageBuffer(size, size)); + + expect(fakeJsi.liveTensors()).toBe(afterConstruction); + }); +}); diff --git a/packages/react-native-executorch/__tests__/tasks/tokenization.test.ts b/packages/react-native-executorch/__tests__/tasks/tokenization.test.ts new file mode 100644 index 0000000000..57275867df --- /dev/null +++ b/packages/react-native-executorch/__tests__/tasks/tokenization.test.ts @@ -0,0 +1,99 @@ +import { createTokenizer } from '../../src/extensions/nlp/tasks/tokenization'; +import { loadTokenizer } from '../../src/extensions/nlp/tokenizer'; +import { fakeJsi } from '../support/fakeJsi'; +import { tracked } from '../support/lifetime'; + +const TOKENIZER_PATH = '/models/tokenizer.json'; +const TOKENS = ['', '', 'hello', 'world', 'react']; + +const register = () => + fakeJsi.registerTokenizer(TOKENIZER_PATH, { + tokens: TOKENS, + prefix: [0], + suffix: [1], + specialIds: [0, 1], + }); + +describe('loadTokenizer', () => { + it('propagates a load failure', () => { + expect(() => loadTokenizer('/models/absent.json')).toThrow(/absent.json/); + }); + + it('exposes the loaded path', () => { + register(); + const tokenizer = loadTokenizer(TOKENIZER_PATH); + expect(tokenizer.path).toBe(TOKENIZER_PATH); + tokenizer.dispose(); + }); +}); + +describe('createTokenizer', () => { + beforeEach(register); + + it('encodes to an Int32Array, adding the post-processor special tokens', async () => { + const tokenizer = tracked(await createTokenizer(TOKENIZER_PATH)); + + const ids = await tokenizer.encode('hello world'); + + expect(ids).toBeInstanceOf(Int32Array); + expect([...ids]).toEqual([0, 2, 3, 1]); + }); + + it('round-trips text through encode and decode', async () => { + const tokenizer = tracked(await createTokenizer(TOKENIZER_PATH)); + + const ids = await tokenizer.encode('hello world'); + + expect(await tokenizer.decode(ids)).toBe('hello world'); + }); + + it('keeps the special tokens when asked not to skip them', async () => { + const tokenizer = tracked(await createTokenizer(TOKENIZER_PATH)); + + const ids = await tokenizer.encode('hello'); + + expect(await tokenizer.decode(ids, false)).toBe(' hello '); + }); + + it('reports the vocabulary size', async () => { + const tokenizer = tracked(await createTokenizer(TOKENIZER_PATH)); + expect(tokenizer.getVocabSize()).toBe(TOKENS.length); + }); + + it('maps between ids and tokens in both directions', async () => { + const tokenizer = tracked(await createTokenizer(TOKENIZER_PATH)); + + expect(tokenizer.idToToken(2)).toBe('hello'); + expect(tokenizer.tokenToId('hello')).toBe(2); + }); + + it('throws for an id outside the vocabulary', async () => { + const tokenizer = tracked(await createTokenizer(TOKENIZER_PATH)); + expect(() => tokenizer.idToToken(999)).toThrow(/out of range/); + }); + + it('throws for a token outside the vocabulary', async () => { + const tokenizer = tracked(await createTokenizer(TOKENIZER_PATH)); + expect(() => tokenizer.tokenToId('absent')).toThrow(/not in the vocabulary/); + }); + + it('surfaces a load failure as a rejected promise', async () => { + await expect(createTokenizer('/models/absent.json')).rejects.toThrow(/absent.json/); + }); + + it('releases the native tokenizer on dispose', async () => { + const tokenizer = await createTokenizer(TOKENIZER_PATH); + expect(fakeJsi.liveTokenizers()).toEqual([TOKENIZER_PATH]); + + tokenizer.dispose(); + + expect(fakeJsi.liveTokenizers()).toEqual([]); + }); + + it('rejects use after dispose', async () => { + const tokenizer = await createTokenizer(TOKENIZER_PATH); + tokenizer.dispose(); + + await expect(tokenizer.encode('hello')).rejects.toThrow(/disposed/); + }); +}); From 091e13bf820813578c50337b843d372a4bfe3f5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20S=C5=82uszniak?= Date: Fri, 7 Aug 2026 16:21:11 +0200 Subject: [PATCH 4/7] test(ts): cover the public API surface, model registry and conventions - A snapshot of every export, so an addition, rename or removal shows up in the diff of the pull request that causes it rather than in a user's app. - Registry rules that only fail on a device otherwise: https URLs on the software-mansion org, a pinned revision, the `modelname_backend_precision.pte` naming contract, a folder matching the backend suffix, and a default alias structurally identical to one of its own variants. - Label-array invariants, including the ImageNet duplicates that must stay because the array mirrors the model's output vocabulary. - Source-level conventions, parsed with the TypeScript compiler: the `'worklet'` directive on every JSI wrapper, and the core/extensions and hooks/native import boundaries from the architecture guide. --- .../api/__snapshots__/apiSurface.test.ts.snap | 117 ++++++++ .../__tests__/api/apiSurface.test.ts | 69 +++++ .../__tests__/api/constants.test.ts | 146 ++++++++++ .../__tests__/api/modelRegistry.test.ts | 250 ++++++++++++++++++ .../__tests__/api/workletDirective.test.ts | 198 ++++++++++++++ 5 files changed, 780 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/constants.test.ts create mode 100644 packages/react-native-executorch/__tests__/api/modelRegistry.test.ts create mode 100644 packages/react-native-executorch/__tests__/api/workletDirective.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..1c694ad61a --- /dev/null +++ b/packages/react-native-executorch/__tests__/api/__snapshots__/apiSurface.test.ts.snap @@ -0,0 +1,117 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`public API surface exports one hook per user-facing task 1`] = ` +[ + "useClassifier", + "useImageEmbedder", + "useInstanceSegmenter", + "useKeypointDetector", + "useModel", + "useObjectDetector", + "useResourceDownload", + "useSemanticSegmenter", + "useSpeechToText", + "useStyleTransfer", + "useTextEmbedder", + "useTextToImage", + "useTokenizer", + "useVoiceActivityDetector", +] +`; + +exports[`public API surface matches the recorded contents of each namespace 1`] = ` +{ + "constants": [ + "BLAZEFACE_LANDMARKS", + "COCO_CLASSES", + "COCO_CLASSES_YOLO", + "COCO_LANDMARKS", + "IMAGENET1K_LABELS", + "IMAGENET_NORM", + "PASCAL_VOC_LABELS", + ], + "cv": [ + "boxes", + "image", + "points", + ], + "math": [ + "argmax", + "mulberry32", + "randomNormal", + "sigmoid", + "softmax", + "threshold", + ], + "nlp": [ + "loadTokenizer", + ], + "schema": [ + "ConstantDim", + "DynamicDim", + "EnumDim", + "RangeDim", + "StaticDim", + "SymbolicTensor", + "constr", + "f32", + "i32", + "i64", + "method", + "ui8", + "validateSpec", + ], + "speech": [ + "extractFrames", + ], +} +`; + +exports[`public API surface matches the recorded export list 1`] = ` +[ + "FSMN_VAD_SAMPLE_RATE_HZ", + "WHISPER_LANGUAGES", + "WHISPER_SAMPLE_RATE_HZ", + "constants", + "createClassifier", + "createFsmnVoiceActivityDetector", + "createImageEmbedder", + "createInstanceSegmenter", + "createKeypointDetector", + "createObjectDetector", + "createSdxsTextToImage", + "createSemanticSegmenter", + "createStyleTransfer", + "createTextEmbedder", + "createTokenizer", + "createWhisperSpeechToText", + "cv", + "defaultWorkletRuntime", + "download", + "getRegisteredBackends", + "inspectModel", + "loadModel", + "math", + "models", + "nlp", + "schema", + "setTelemetryEnabled", + "speech", + "tensor", + "useClassifier", + "useImageEmbedder", + "useInstanceSegmenter", + "useKeypointDetector", + "useModel", + "useObjectDetector", + "useResourceDownload", + "useSemanticSegmenter", + "useSpeechToText", + "useStyleTransfer", + "useTextEmbedder", + "useTextToImage", + "useTokenizer", + "useVoiceActivityDetector", + "wrapAsync", +] +`; 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..73e6686637 --- /dev/null +++ b/packages/react-native-executorch/__tests__/api/apiSurface.test.ts @@ -0,0 +1,69 @@ +/** + * The public export surface. + * + * A snapshot is a blunt instrument, but it is the right one here: this package + * is consumed as a library, so *every* addition, rename and removal in + * `src/index.ts` is an API event. The snapshot makes each of them show up in + * the diff of the pull request that causes it, rather than in a user's app. + * + * Updating it (`yarn test -u`) is expected — it is a prompt to check the change + * is intended and, when it is a removal or a rename, that it is called out as + * breaking. + */ +import * as api from '../../src/index'; + +/** The namespace re-exports (`export * as math from ...`) plus the registry. */ +const NAMESPACES = ['constants', 'cv', 'math', 'models', 'nlp', 'schema', 'speech'] as const; + +describe('public API surface', () => { + it('matches the recorded export list', () => { + expect(Object.keys(api).sort()).toMatchSnapshot(); + }); + + it.each(NAMESPACES)('exposes the %s namespace as an object', (name) => { + expect(typeof (api as Record)[name]).toBe('object'); + }); + + it('matches the recorded contents of each namespace', () => { + const namespaces = Object.fromEntries( + NAMESPACES.filter((name) => name !== 'models').map((name) => [ + name, + Object.keys((api as Record)[name] as object).sort(), + ]) + ); + + expect(namespaces).toMatchSnapshot(); + }); + + it('exports one hook per user-facing task', () => { + const hooks = Object.keys(api).filter((key) => key.startsWith('use')); + expect(hooks.sort()).toMatchSnapshot(); + }); + + it('exports a create factory for every task hook that wraps one', () => { + // `useModel` and `useResourceDownload` are generic helpers with no task of + // their own; everything else pairs a hook with a factory. + const generic = new Set(['useModel', 'useResourceDownload']); + const factories = new Set(Object.keys(api).filter((key) => key.startsWith('create'))); + + const unpaired = Object.keys(api) + .filter((key) => key.startsWith('use') && !generic.has(key)) + .filter((hook) => { + const task = hook.slice('use'.length); + return ![...factories].some((factory) => factory.slice('create'.length).includes(task)); + }); + + expect(unpaired).toEqual([]); + }); + + it('exports no symbol whose name starts with an underscore', () => { + expect(Object.keys(api).filter((key) => key.startsWith('_'))).toEqual([]); + }); + + it('exports every value as a function, object or array — never undefined', () => { + const undefinedExports = Object.keys(api).filter( + (key) => (api as Record)[key] === undefined + ); + expect(undefinedExports).toEqual([]); + }); +}); diff --git a/packages/react-native-executorch/__tests__/api/constants.test.ts b/packages/react-native-executorch/__tests__/api/constants.test.ts new file mode 100644 index 0000000000..4beb47c73d --- /dev/null +++ b/packages/react-native-executorch/__tests__/api/constants.test.ts @@ -0,0 +1,146 @@ +/** + * Invariants of the label and landmark constants. + * + * These arrays are positional: index `i` has to be the class the model emits at + * output slot `i`. Nothing in the type system enforces that, and a single + * inserted or deleted entry shifts every label after it — producing confidently + * wrong predictions rather than an error. + */ +import * as constants from '../../src/constants'; +import { models } from '../../src/models'; + +// Sizes are asserted by name rather than by passing the arrays through +// `it.each`, which would print a thousand ImageNet labels into the test title. +const VOCABULARIES = { + IMAGENET1K_LABELS: 1000, + PASCAL_VOC_LABELS: 21, + COCO_CLASSES: 91, + COCO_CLASSES_YOLO: 80, +} as const; + +const vocabulary = (name: keyof typeof VOCABULARIES): readonly string[] => constants[name]; + +describe('dataset label arrays', () => { + it.each(Object.entries(VOCABULARIES))('%s has exactly %i entries', (name, expected) => { + expect(vocabulary(name as keyof typeof VOCABULARIES)).toHaveLength(expected); + }); + + it.each(Object.keys(VOCABULARIES))('%s holds only non-empty, trimmed strings', (name) => { + for (const label of vocabulary(name as keyof typeof VOCABULARIES)) { + expect(typeof label).toBe('string'); + expect(label.length).toBeGreaterThan(0); + expect(label).toBe(label.trim()); + } + }); + + it('starts PASCAL VOC and COCO with their background class', () => { + expect(constants.PASCAL_VOC_LABELS[0]).toBe('background'); + expect(constants.COCO_CLASSES[0]).toBe('background'); + }); + + it('keeps the COCO id gaps padded, so class ids stay aligned', () => { + // The 91-entry COCO list is index-aligned to the dataset's sparse ids, with + // the unused ones filled in — dropping them would shift every later class. + expect(constants.COCO_CLASSES.filter((label) => label === 'N/A').length).toBe( + constants.COCO_CLASSES.length - constants.COCO_CLASSES_YOLO.length - 1 + ); + }); + + it('starts the YOLO class list at a real class, with no background slot', () => { + expect(constants.COCO_CLASSES_YOLO[0]).toBe('person'); + expect(constants.COCO_CLASSES_YOLO).not.toContain('__background__'); + }); + + it('keeps the YOLO list a subset of the padded COCO list', () => { + const padded = new Set(constants.COCO_CLASSES); + expect(constants.COCO_CLASSES_YOLO.filter((label) => !padded.has(label))).toEqual([]); + }); + + it('has no duplicates in COCO once the padding is removed', () => { + const real = constants.COCO_CLASSES.filter((label) => label !== 'N/A'); + expect(new Set(real).size).toBe(real.length); + }); + + it('leaves ImageNet duplicates in place — the array mirrors the model vocabulary', () => { + // ImageNet-1k genuinely repeats a couple of names ("maillot", "crane"). + // The array has to keep them: dropping one would shift every later index. + expect(new Set(constants.IMAGENET1K_LABELS).size).toBeLessThan( + constants.IMAGENET1K_LABELS.length + ); + }); + + it('has no duplicates in the deduplicated detection vocabularies', () => { + expect(new Set(constants.COCO_CLASSES_YOLO).size).toBe(constants.COCO_CLASSES_YOLO.length); + expect(new Set(constants.PASCAL_VOC_LABELS).size).toBe(constants.PASCAL_VOC_LABELS.length); + }); +}); + +describe('landmark arrays', () => { + it('lists the six BlazeFace landmarks', () => { + expect(constants.BLAZEFACE_LANDMARKS).toHaveLength(6); + }); + + it('lists the seventeen COCO body keypoints', () => { + expect(constants.COCO_LANDMARKS).toHaveLength(17); + }); + + it.each([ + ['BLAZEFACE_LANDMARKS', constants.BLAZEFACE_LANDMARKS], + ['COCO_LANDMARKS', constants.COCO_LANDMARKS], + ] as const)('%s names every landmark uniquely', (_name, landmarks) => { + expect(new Set(landmarks).size).toBe(landmarks.length); + }); + + it('names COCO keypoints in the canonical order', () => { + expect(constants.COCO_LANDMARKS[0]).toBe('nose'); + expect(constants.COCO_LANDMARKS.at(-1)).toBe('rightAnkle'); + }); + + it('names every landmark in camelCase', () => { + for (const landmark of [...constants.BLAZEFACE_LANDMARKS, ...constants.COCO_LANDMARKS]) { + expect(landmark).toMatch(/^[a-z][a-zA-Z]*$/); + } + }); +}); + +describe('IMAGENET_NORM', () => { + it('carries a per-channel mean and standard deviation', () => { + expect(constants.IMAGENET_NORM.alpha).toHaveLength(3); + expect(constants.IMAGENET_NORM.beta).toHaveLength(3); + }); + + it('holds finite coefficients', () => { + expect( + [...constants.IMAGENET_NORM.alpha, ...constants.IMAGENET_NORM.beta].every(Number.isFinite) + ).toBe(true); + }); +}); + +describe('registry ↔ constants alignment', () => { + /** Every label array actually referenced by a registry entry. */ + const referenced = (function collect(node: unknown): unknown[][] { + if (Array.isArray(node)) return []; + if (node && typeof node === 'object') { + const own = 'labels' in node ? [(node as { labels: unknown[] }).labels] : []; + return [...own, ...Object.values(node).flatMap(collect)]; + } + return []; + })(models); + + it('references at least one label array', () => { + expect(referenced.length).toBeGreaterThan(0); + }); + + it('sources every dataset-sized vocabulary from constants.ts', () => { + // Small inline arrays are fine — a binary segmenter's `['background', + // 'person']` needs no shared constant. A full dataset vocabulary pasted + // into `models.ts` would be a second copy free to drift from this one. + const exported = new Set(Object.values(constants)); + const orphans = referenced + .filter((labels) => labels.length > 5) + .filter((labels) => !exported.has(labels)) + .map((labels) => `${labels.length} labels starting ${String(labels[0])}`); + + expect(orphans).toEqual([]); + }); +}); 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..4fd221bd8e --- /dev/null +++ b/packages/react-native-executorch/__tests__/api/modelRegistry.test.ts @@ -0,0 +1,250 @@ +/** + * Rules the `models` registry has to satisfy. + * + * The registry is the only thing standing between an app and a 404 at runtime: + * a typo in a URL, a backend suffix that disagrees with the folder it lives in, + * or a default alias that drifted from the variant it is supposed to mirror all + * type-check cleanly and only fail on a device, after a download. + * + * The registry nests irregularly — a category holds entries, an entry may hold + * size groups, and a size group holds backend variants — so everything here is + * driven by a recursive walk rather than by an assumed depth. + */ +import { models } from '../../src/models'; + +const BASE_URL = 'https://huggingface.co/software-mansion/react-native-executorch'; + +type Node = Record; + +const isObject = (value: unknown): value is Node => + typeof value === 'object' && value !== null && !Array.isArray(value); + +/** + * A leaf that a `create` factory could be handed: it names a model file. + */ +const isConfig = (value: unknown): value is Node => + isObject(value) && typeof value.modelPath === 'string'; + +/** + * A backend variant key, e.g. `XNNPACK_FP32`. Size keys (`TINY`) look the same. + */ +const isUpperKey = (key: string) => /^[A-Z0-9_]+$/.test(key); + +/** + * A node's own config fields, with any nested variant/size groups stripped. + */ +const configPart = (node: Node): Node => + Object.fromEntries(Object.entries(node).filter(([key]) => !isUpperKey(key))); + +/** + * Every `[path, url]` string leaf that looks like a URL. + */ +function urlLeaves(node: unknown, path: string[] = []): [string, string][] { + if (typeof node === 'string') return /^https?:/i.test(node) ? [[path.join('.'), node]] : []; + if (Array.isArray(node)) return node.flatMap((item, i) => urlLeaves(item, [...path, `${i}`])); + if (isObject(node)) { + return Object.entries(node).flatMap(([key, value]) => urlLeaves(value, [...path, key])); + } + return []; +} + +/** + * Every task config in the registry, with the dotted path it sits at. + */ +function configs(node: unknown, path: string[] = []): { label: string; config: Node }[] { + if (!isObject(node)) return []; + const here = isConfig(node) ? [{ label: path.join('.'), config: node }] : []; + const nested = Object.entries(node) + .filter(([, value]) => isObject(value)) + .flatMap(([key, value]) => configs(value, [...path, key])); + return [...here, ...nested]; +} + +/** + * Every group that both spreads a default config and lists named variants — + * the `{ ...X_FP32, XNNPACK_FP32: X_FP32, COREML_FP16: X_FP16 }` shape. + */ +function variantGroups(node: unknown, path: string[] = []): { label: string; group: Node }[] { + if (!isObject(node)) return []; + const variants = Object.entries(node).filter( + ([key, value]) => isUpperKey(key) && isConfig(value) + ); + const here = + variants.length > 0 && isConfig(node) ? [{ label: path.join('.'), group: node }] : []; + const nested = Object.entries(node) + .filter(([, value]) => isObject(value)) + .flatMap(([key, value]) => variantGroups(value, [...path, key])); + return [...here, ...nested]; +} + +const urls = urlLeaves(models); +const allConfigs = configs(models); +const allGroups = variantGroups(models); + +describe('models registry — URLs', () => { + it('finds URLs in every category', () => { + for (const category of Object.keys(models)) { + expect(urls.filter(([path]) => path.startsWith(`${category}.`)).length).toBeGreaterThan(0); + } + }); + + it.each(urls)('%s is served over https from the software-mansion org', (_path, url) => { + expect(url.startsWith(`${BASE_URL}-`)).toBe(true); + }); + + it.each(urls)('%s pins an explicit revision', (_path, url) => { + expect(url).toMatch(/\/resolve\/v\d+\.\d+\.\d+\//); + }); + + it.each(urls)('%s is free of whitespace and empty path segments', (_path, url) => { + expect(url).not.toMatch(/\s/); + expect(url.replace('https://', '')).not.toMatch(/\/\//); + }); + + it('names every .pte after the modelname_backend_precision contract', () => { + const offenders = urls + .filter(([, url]) => url.endsWith('.pte')) + .filter(([, url]) => { + const parts = url.split('/').pop()!.replace('.pte', '').split('_'); + const backend = parts.at(-2) ?? ''; + const precision = parts.at(-1) ?? ''; + return ( + !/^(xnnpack|coreml|mlx|qnn|vulkan)$/.test(backend) || + !/^(fp32|fp16|bf16|int8|int4|8da4w|4w|dynamic)$/.test(precision) + ); + }) + .map(([path, url]) => `${path}: ${url.split('/').pop()}`); + + expect(offenders).toEqual([]); + }); + + it('stores every .pte in a folder matching its backend suffix', () => { + const offenders = urls + .filter(([, url]) => url.endsWith('.pte')) + .filter(([, url]) => { + const segments = url.split('/'); + return segments.at(-2) !== segments.at(-1)!.replace('.pte', '').split('_').at(-2); + }) + .map(([path, url]) => `${path}: ${url}`); + + expect(offenders).toEqual([]); + }); + + it('points every tokenizer entry at a tokenizer.json', () => { + const tokenizerUrls = urls.filter(([path]) => /tokenizerPath|^tokenizer\./.test(path)); + expect(tokenizerUrls.length).toBeGreaterThan(0); + for (const [, url] of tokenizerUrls) expect(url.endsWith('tokenizer.json')).toBe(true); + }); +}); + +describe('models registry — structure', () => { + it('exports the single models object and nothing else', async () => { + const registry = await import('../../src/models'); + expect(Object.keys(registry)).toEqual(['models']); + }); + + it('reaches a task config in every category', () => { + for (const category of Object.keys(models)) { + const inCategory = allConfigs.filter(({ label }) => label.startsWith(`${category}.`)); + // `tokenizer` holds bare URL strings rather than configs. + if (category === 'tokenizer') continue; + expect(inCategory.length).toBeGreaterThan(0); + } + }); + + it('finds variant groups to check', () => { + expect(allGroups.length).toBeGreaterThan(10); + }); + + it.each(allGroups)('$label defaults to one of its own variants', ({ group }) => { + // Compare config fields only: a variant may itself carry further nested + // groups (a size family, say), which the spread default never includes. + const variants = Object.entries(group) + .filter(([key, value]) => isUpperKey(key) && isConfig(value)) + .map(([, value]) => JSON.stringify(configPart(value as Node))); + const defaults = JSON.stringify(configPart(group)); + + // The default is spread in alongside the variants, so it must be + // structurally identical to one of them — otherwise `models.x.Y` and + // `models.x.Y.XNNPACK_FP32` silently disagree. + expect(variants).toContain(defaults); + }); + + it.each(allGroups)('$label gives every variant a distinct model path', ({ group }) => { + const paths = Object.entries(group) + .filter(([key, value]) => isUpperKey(key) && isConfig(value)) + .map(([, value]) => (value as Node).modelPath); + + expect(new Set(paths).size).toBe(paths.length); + }); + + it('uses distinct entry names within each category', () => { + for (const group of Object.values(models)) { + const names = Object.keys(group as Node); + expect(new Set(names).size).toBe(names.length); + } + }); +}); + +describe('models registry — task configs', () => { + const withLabels = allConfigs.filter(({ config }) => + Array.isArray((config.modelOpts as Node | undefined)?.labels) + ); + + it('finds label vocabularies to check', () => { + expect(withLabels.length).toBeGreaterThan(0); + }); + + it.each(withLabels)('$label has a non-empty label vocabulary', ({ config }) => { + // Not asserted unique: ImageNet-1k genuinely repeats a few class names, and + // the array has to mirror the model's output vocabulary exactly. + expect(((config.modelOpts as Node).labels as unknown[]).length).toBeGreaterThan(0); + }); + + // Only the speech-to-text configs themselves, not the VAD config nested inside them. + it.each(allConfigs.filter(({ config }) => Array.isArray(config.supportedLanguages)))( + '$label bundles a tokenizer, a VAD model and its supported languages', + ({ config }) => { + expect(typeof config.tokenizerPath).toBe('string'); + expect(config.vadModel).toMatchObject({ modelPath: expect.any(String) }); + expect(config.supportedLanguages).toEqual(expect.any(Array)); + expect((config.supportedLanguages as unknown[]).length).toBeGreaterThan(0); + } + ); + + it.each(allConfigs.filter(({ label }) => label.startsWith('textToImage.')))( + '$label bundles a tokenizer', + ({ config }) => { + expect(typeof config.tokenizerPath).toBe('string'); + } + ); + + it.each(allConfigs.filter(({ label }) => label.startsWith('textEmbeddings.')))( + '$label bundles a tokenizer', + ({ config }) => { + expect(typeof config.tokenizerPath).toBe('string'); + } + ); + + it('keeps every numeric option finite', () => { + const numbers = (node: unknown): number[] => { + if (typeof node === 'number') return [node]; + if (Array.isArray(node)) return node.flatMap(numbers); + if (isObject(node)) return Object.values(node).flatMap(numbers); + return []; + }; + + expect(numbers(models).filter((n) => !Number.isFinite(n))).toEqual([]); + }); + + it.each(allConfigs.filter(({ config }) => isObject(config.modelOpts)))( + '$label declares the preprocessing every image task needs', + ({ config }) => { + const opts = config.modelOpts as Node; + if (!('resizeMode' in opts)) return; // non-image task + expect(['stretch', 'letterbox', 'crop']).toContain(opts.resizeMode); + expect(['nearest', 'area', 'cubic', 'lanczos', 'linear']).toContain(opts.interpolation); + expect(opts.normalizeOpts).toBeDefined(); + } + ); +}); diff --git a/packages/react-native-executorch/__tests__/api/workletDirective.test.ts b/packages/react-native-executorch/__tests__/api/workletDirective.test.ts new file mode 100644 index 0000000000..325f3278c2 --- /dev/null +++ b/packages/react-native-executorch/__tests__/api/workletDirective.test.ts @@ -0,0 +1,198 @@ +/** + * The `'worklet'` directive convention. + * + * `.agents/skills/core-guidelines/SKILL.md` states it plainly: every + * TypeScript function that wraps a native JSI call has to start with + * `'worklet';`, so the function can be serialized onto a worklet runtime. + * + * Nothing enforces it. A missing directive is invisible on the JS thread — the + * function works exactly as before — and only fails once someone calls the + * pipeline from a worklet (a camera frame processor, an audio callback), with + * an error that points at the call site rather than at the omission. + * + * So the convention is checked the only way it can be: by reading the source. + * The reading is done with the TypeScript parser rather than regular + * expressions — an object type in a parameter list (`opts: { mode: string }`) + * defeats brace counting, and overload declarations have no body at all. + */ +import { readFileSync, readdirSync, statSync } from 'fs'; +import { join } from 'path'; +import ts from 'typescript'; + +const SRC = join(__dirname, '..', '..', 'src'); + +/** + * Every `.ts` file under `src/`, relative to it. + */ +function sourceFiles(directory = SRC, prefix = ''): string[] { + return readdirSync(directory).flatMap((entry) => { + const full = join(directory, entry); + const relative = prefix ? `${prefix}/${entry}` : entry; + if (statSync(full).isDirectory()) return sourceFiles(full, relative); + return entry.endsWith('.ts') ? [relative] : []; + }); +} + +type FunctionInfo = { + file: string; + name: string; + label: string; + hasDirective: boolean; + callsJsi: boolean; + isAsync: boolean; +}; + +/** + * Whether a function body opens with the `'worklet'` directive prologue. + */ +function hasWorkletDirective(body: ts.Block): boolean { + const first = body.statements[0]; + return ( + !!first && + ts.isExpressionStatement(first) && + ts.isStringLiteral(first.expression) && + first.expression.text === 'worklet' + ); +} + +/** + * Whether a body calls a method on the JSI global (a property read is not a call). + */ +function callsJsiFunction(body: ts.Node): boolean { + let found = false; + const visit = (node: ts.Node): void => { + if (found) return; + if (ts.isCallExpression(node) && /(^|\.)rnexecutorchJsi\./.test(node.expression.getText())) { + found = true; + return; + } + ts.forEachChild(node, visit); + }; + ts.forEachChild(body, visit); + return found; +} + +/** + * Every exported top-level function in a file, with what the check needs to know. + */ +function exportedFunctions(file: string): FunctionInfo[] { + const source = ts.createSourceFile( + file, + readFileSync(join(SRC, file), 'utf8'), + ts.ScriptTarget.Latest, + true + ); + + const results: FunctionInfo[] = []; + + for (const statement of source.statements) { + const modifiers = ts.canHaveModifiers(statement) ? ts.getModifiers(statement) : undefined; + if (!modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword)) continue; + + /** + * Records one function-like node under `name`. + */ + const record = (name: string, node: ts.FunctionLikeDeclaration) => { + const body = node.body; + if (!body || !ts.isBlock(body)) return; // an overload signature has none + results.push({ + file, + name, + label: `${file} → ${name}()`, + hasDirective: hasWorkletDirective(body), + callsJsi: callsJsiFunction(body), + isAsync: !!node.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.AsyncKeyword), + }); + }; + + if (ts.isFunctionDeclaration(statement) && statement.name) { + record(statement.name.text, statement); + } + + if (ts.isVariableStatement(statement)) { + for (const declaration of statement.declarationList.declarations) { + const initializer = declaration.initializer; + if (!initializer || !ts.isIdentifier(declaration.name)) continue; + if (ts.isArrowFunction(initializer) || ts.isFunctionExpression(initializer)) { + record(declaration.name.text, initializer); + } + } + } + } + + return results; +} + +const files = sourceFiles(); +const allFunctions = files.flatMap(exportedFunctions); + +/** Exported functions that call into the JSI global from their own body. */ +const jsiWrappers = allFunctions.filter(({ callsJsi }) => callsJsi); + +describe('worklet directive', () => { + it('finds the JSI wrappers to check', () => { + // A refactor that moved every JSI call behind a new indirection would + // otherwise make this suite pass by checking nothing. + expect(jsiWrappers.length).toBeGreaterThan(10); + }); + + it.each(jsiWrappers.filter(({ isAsync }) => !isAsync))( + '$label starts with the worklet directive', + ({ hasDirective }) => { + expect(hasDirective).toBe(true); + } + ); + + it('never marks an async function as a worklet', () => { + // A worklet runs synchronously on its runtime; an `async` one would be + // serialized but could never be awaited there. + const offenders = allFunctions + .filter(({ isAsync, hasDirective }) => isAsync && hasDirective) + .map(({ label }) => label); + + expect(offenders).toEqual([]); + }); + + it('marks every exported function in the extension op modules', () => { + // `src/extensions/**/ops/` and `math.ts` are the thin native wrappers; each + // of their exports is meant to be worklet-callable. + const opModules = allFunctions.filter( + ({ file }) => /extensions\/[^/]+\/ops\/\w+\.ts$/.test(file) || file === 'extensions/math.ts' + ); + expect(opModules.length).toBeGreaterThan(0); + + const missing = opModules + .filter(({ hasDirective, isAsync }) => !hasDirective && !isAsync) + .map(({ label }) => label); + + expect(missing).toEqual([]); + }); +}); + +describe('architecture boundaries', () => { + it('imports the JSI global only through src/native/bridge.ts', () => { + const offenders = files + .filter((file) => file !== 'native/bridge.ts') + .filter((file) => readFileSync(join(SRC, file), 'utf8').includes('__rnexecutorch_jsi__')); + + expect(offenders).toEqual([]); + }); + + it('keeps core/ free of domain-specific extension imports', () => { + // The core/extensions split from the architecture guide: core is + // domain-agnostic, so it must never reach sideways into `extensions/`. + const offenders = files + .filter((file) => file.startsWith('core/')) + .filter((file) => /from '.*extensions\//.test(readFileSync(join(SRC, file), 'utf8'))); + + expect(offenders).toEqual([]); + }); + + it('keeps hooks/ out of the native layer, so they compose task pipelines only', () => { + const offenders = files + .filter((file) => file.startsWith('hooks/')) + .filter((file) => /from '.*native\//.test(readFileSync(join(SRC, file), 'utf8'))); + + expect(offenders).toEqual([]); + }); +}); From 7f43748682787faf225650d4d78ba8f0a730014f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20S=C5=82uszniak?= Date: Fri, 7 Aug 2026 16:21:28 +0200 Subject: [PATCH 5/7] fix(ts): correct three contract violations the API suites surfaced MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `SpecMatch.dim(name)` with no expected kind is typed and documented to return the raw `ConcreteDim`, but unwrapped it anyway — so `dim('N')` handed back a number typed as an object, and `dims.any(...)` a tuple of them. Unwrap only when a kind was asked for. Nothing in `src/` used the no-kind form. - `getRegisteredBackends` calls straight into the JSI global without the `'worklet'` directive the architecture guide requires, so it could not be called from a worklet runtime like every other native wrapper. - `randomNormal`'s default seed is `Date.now()`, whose millisecond resolution means two unseeded calls in the same millisecond draw the identical sequence — the opposite of what its own JSDoc promises. Mix in `Math.random`; an explicit seed still wins, so reproducibility is unaffected. --- .../src/core/schema.ts | 22 +++++++++---------- .../src/extensions/math.ts | 7 +++++- packages/react-native-executorch/src/utils.ts | 1 + 3 files changed, 18 insertions(+), 12 deletions(-) diff --git a/packages/react-native-executorch/src/core/schema.ts b/packages/react-native-executorch/src/core/schema.ts index f6a83314c0..731d7d45f5 100644 --- a/packages/react-native-executorch/src/core/schema.ts +++ b/packages/react-native-executorch/src/core/schema.ts @@ -782,21 +782,21 @@ export function validateSpec { diff --git a/packages/react-native-executorch/src/extensions/math.ts b/packages/react-native-executorch/src/extensions/math.ts index 85469455be..343e13b743 100644 --- a/packages/react-native-executorch/src/extensions/math.ts +++ b/packages/react-native-executorch/src/extensions/math.ts @@ -105,7 +105,12 @@ export function randomNormal( 'worklet'; const mean = options?.mean ?? 0; const std = options?.std ?? 1; - const uniform = mulberry32(options?.seed ?? Date.now()); + // `Date.now()` alone has millisecond resolution, so two unseeded calls in the + // same millisecond would draw the identical "random" sequence. Mixing in + // `Math.random` keeps consecutive calls independent; an explicit seed still + // wins, so reproducibility is unaffected. + // eslint-disable-next-line no-bitwise + const uniform = mulberry32(options?.seed ?? (Date.now() ^ (Math.random() * 0x100000000)) >>> 0); const out = new Float32Array(size); for (let i = 0; i < size; i += 2) { // Guard against log(0) when the generator returns exactly 0. diff --git a/packages/react-native-executorch/src/utils.ts b/packages/react-native-executorch/src/utils.ts index d7ad54f65c..5805ddc152 100644 --- a/packages/react-native-executorch/src/utils.ts +++ b/packages/react-native-executorch/src/utils.ts @@ -11,6 +11,7 @@ import RNBlobUtil from 'react-native-blob-util'; * 'CoreMLBackend'). */ export function getRegisteredBackends(): string[] { + 'worklet'; return rnexecutorchJsi.getExecuTorchRegisteredBackends(); } From e0cd2fd6520da34ac4b71b330d9c41b664f99b68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20S=C5=82uszniak?= Date: Fri, 7 Aug 2026 16:21:28 +0200 Subject: [PATCH 6/7] ci: run the TypeScript API tests, and document the workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an `api-tests` job to CI — no native libraries, no simulator, no `.pte`, so the existing TypeScript-only setup action is all it needs. Adds an `add-api-tests` skill covering the fake runtime, the helpers, what to cover for a new pipeline or hook, and the leak-checking contract; wires it into the skills index, the architecture guide and the maintenance list; and adds the test step to the verify-and-build workflow and checklist. Also refreshes the hook example in `add-task-pipeline`, which still showed the `localPath` shape from before the resource fetcher landed. --- .agents/skills/README.md | 1 + .agents/skills/add-api-tests/SKILL.md | 136 +++++++++++++++++++++ .agents/skills/add-task-pipeline/SKILL.md | 21 ++-- .agents/skills/core-guidelines/SKILL.md | 1 + .agents/skills/skills-maintenance/SKILL.md | 1 + .agents/skills/verify-and-build/SKILL.md | 11 +- .cspell-wordlist.txt | 7 ++ .github/workflows/ci.yml | 18 +++ 8 files changed, 182 insertions(+), 14 deletions(-) create mode 100644 .agents/skills/add-api-tests/SKILL.md diff --git a/.agents/skills/README.md b/.agents/skills/README.md index 4f9d99a0de..28a6d066ad 100644 --- a/.agents/skills/README.md +++ b/.agents/skills/README.md @@ -8,5 +8,6 @@ This directory contains specialized skills (recipes) to guide contributors and A - [Add Native Extension](./add-native-extension/SKILL.md) — C++ operations and JSI bindings. - [Add Task Pipeline](./add-task-pipeline/SKILL.md) — TypeScript task pipelines and React hooks. - [Model Schema Validation](./model-schema-validation/SKILL.md) — Model specs, dynamic shapes, and schema validation. +- [Add API Tests](./add-api-tests/SKILL.md) — TypeScript API test suites and the fake native runtime. - [Verify and Build](./verify-and-build/SKILL.md) — TypeScript typechecking, native rebuilding, and troubleshooting. - [Skills Maintenance](./skills-maintenance/SKILL.md) — Keeping skills synchronized with core primitives. diff --git a/.agents/skills/add-api-tests/SKILL.md b/.agents/skills/add-api-tests/SKILL.md new file mode 100644 index 0000000000..3a8f306c2e --- /dev/null +++ b/.agents/skills/add-api-tests/SKILL.md @@ -0,0 +1,136 @@ +--- +name: add-api-tests +description: Use when adding or changing anything under src/ — a task pipeline, a hook, a native op wrapper, a registry entry — and you need to cover it with the TypeScript API test suites. +metadata: + id: add_api_tests + scope: packages/react-native-executorch/__tests__/* +--- + +# Skill: Add TypeScript API Tests + +Every change under `src/` belongs in the Jest suites at +[`packages/react-native-executorch/__tests__/`](../../../packages/react-native-executorch/__tests__/README.md). +They run on a laptop or a CI runner — no simulator, no device, no `.pte` — and +finish in a few seconds. + +```bash +yarn workspace react-native-executorch test +yarn workspace react-native-executorch test __tests__/tasks # one directory +yarn workspace react-native-executorch test -u # update snapshots +``` + +Types come along for free: `yarn typecheck` already covers `__tests__/`. + +--- + +## 🧩 The Fake Native Runtime + +There is no stubbing of individual JSI calls. `__tests__/support/fakeJsi.ts` +implements the whole `__rnexecutorch_jsi__` contract in JavaScript — tensors +hold real data, `math`/`cv`/`speech` operators compute real values — so a task +pipeline runs end to end and its own logic is what the assertions measure. + +A test describes the model it wants, then drives the real pipeline: + +```typescript +import { f32, method } from '../../src/core/schema'; +import { fakeJsi } from '../support/fakeJsi'; +import { tracked } from '../support/lifetime'; +import { STRETCH_PREPROCESSING, exported, imageBuffer, writesOutputs } from '../support/fixtures'; + +fakeJsi.registerModel('/models/task.pte', { + schema: exported(method('forward', [f32(1, 3, 4, 4)], [f32(1, 3)])), + execute: writesOutputs([1, 0, 2]), +}); + +const runner = tracked(await createMyTask({ modelPath: '/models/task.pte', modelOpts })); +expect(await runner.runTask(imageBuffer(8, 8))).toEqual(/* ... */); +``` + +Key helpers: + +| Helper | Use | +| :--- | :--- | +| `fakeJsi.registerModel(path, program)` | Make `loadModel(path)` succeed with a given schema and `execute` | +| `fakeJsi.registerTokenizer(path, vocabulary)` | Same for `loadTokenizer` | +| `exported(spec)` | Reinterpret a spec built with `method`/`f32`/`i64` as an *exported* one (it verifies no symbolic dims are left) | +| `writesOutputs(...)`, `copiesInputToOutput()` | Ready-made `execute` implementations | +| `tracked(pipeline)` | Auto-dispose at the end of the test | +| `imageBuffer(w, h, format)` | A deterministic input image | +| `cachePathFor(url)` | Where the fetcher will download a URL, so a hook test can register its model up front | +| `fakeNet.serve(url, route)` | Script the server: status, body, `Range` support, and a `gate` to hold a download open | + +--- + +## 🧠 What to Cover for a New Task Pipeline + +1. **Schema acceptance** — one test per variant the pipeline declares + (`batched`, `unbatched`, ...), asserting the factory resolves. +2. **Schema rejection** — a model that matches no variant, asserting the error + names the mismatch (`Rank mismatch`, `inconsistent bindings`, ...). A caller + should learn what is wrong from the message. +3. **Configuration mismatch** — e.g. a `labels` array that disagrees with the + model's output dimension. +4. **Postprocessing** — the part that is yours: sorting, thresholding, + suppression, colormaps, coordinate scaling. Choose fixture values that make + the expected output obvious in the test. +5. **Options** — every default in `modelOpts`, and every per-call override. +6. **Disposal** — `dispose()` leaves `fakeJsi.liveTensors()` at 0 and + `fakeJsi.liveModels()` empty, and repeated calls do not accumulate scratch + tensors. +7. **Sync/async parity** — `runTaskWorklet(x)` equals `await runTask(x)`. + +For a new hook, add a case to `__tests__/hooks/`: not-ready before the download +lands, methods exposed after, errors surfaced through the shared `error` field, +and every native handle released on unmount. + +--- + +## 🔒 Leak Checking + +Native memory is not garbage collected, so the setup file asserts after **every +test** that nothing allocated through the fake was left undisposed. That gives +each pipeline suite disposal coverage for free. + +- Wrap construction in `tracked()` — it disposes at the end of the test and + stops a failing assertion from cascading into a second, misleading error. +- A test that deliberately leaks calls `allowNativeLeaks()` with a comment + saying why. + +--- + +## 📐 Source-Level Conventions + +`__tests__/api/workletDirective.test.ts` parses `src/` with the TypeScript +compiler and enforces the conventions no type can express: + +- every exported function that calls into `rnexecutorchJsi` starts with + `'worklet';` +- no `async` function is marked as a worklet +- only `src/native/bridge.ts` names the `__rnexecutorch_jsi__` global +- `core/` never imports from `extensions/`, and `hooks/` never imports from + `native/` + +If you add a new native wrapper without the directive, that suite fails — add +the directive rather than the exception. + +--- + +## 📋 Verification Checklist + +When adding or changing code under `src/`, verify that: + +- [ ] `yarn workspace react-native-executorch test` passes. +- [ ] A new task pipeline has a suite covering acceptance, rejection, + postprocessing, options and disposal. +- [ ] A new hook has a lifecycle case in `__tests__/hooks/`. +- [ ] A new registry entry passes `__tests__/api/modelRegistry.test.ts` without + the rules being loosened (https URL, pinned revision, + `modelname_backend_precision.pte`, backend-matching folder, default + aliasing one of its own variants). +- [ ] A new export is reflected in the `api/apiSurface` snapshot, and the change + is intentional (a removal or rename is a breaking change). +- [ ] Any new fake behaviour in `__tests__/support/` is faithful where fidelity + changes an assertion, and its simplifications are commented. +- [ ] No test was made to pass by calling `allowNativeLeaks()` without an + explanation. diff --git a/.agents/skills/add-task-pipeline/SKILL.md b/.agents/skills/add-task-pipeline/SKILL.md index f8116dfa40..ed886efa4f 100644 --- a/.agents/skills/add-task-pipeline/SKILL.md +++ b/.agents/skills/add-task-pipeline/SKILL.md @@ -188,28 +188,22 @@ Wrap the task pipeline in a custom React Hook using the core hooks `useResourceD ```typescript import { useModel } from './useModel'; -import { useResourceDownload } from './useResourceDownload'; +import { useResourceDownload, type ResourceOptions } from './useResourceDownload'; import { createMyTask, type MyTaskModel } from '../extensions//tasks/'; -export function useMyTask(config: MyTaskModel, options?: { preventLoad?: boolean }) { - // 1. Resolve remote or local asset model path and download progress - const { localPath, downloadProgress, downloadError } = useResourceDownload( - config.modelPath, - options?.preventLoad - ); +export function useMyTask(config: MyTaskModel, options?: ResourceOptions) { + // 1. Resolve every remote URL in the config to a local path, with progress + // weighted across all of them. + const { resource, downloadProgress, downloadError } = useResourceDownload(config, options); // 2. Instantiate and compile the task pipeline (with automatic lifecycle cleanup) - const { model, error } = useModel( - createMyTask, - localPath ? { ...config, modelPath: localPath } : null, - [localPath] - ); + const { model, error } = useModel(createMyTask, resource ?? null); return { isReady: !!model, error: downloadError || error, downloadProgress, - localPath, + resource, runTask: model?.runTask, runTaskWorklet: model?.runTaskWorklet, }; @@ -233,3 +227,4 @@ When adding a task pipeline or React hook, verify that: - [ ] Every parameter is bucketed per Principle 6: varies across variants → option; fixed by the export → `const` in the task file; per-call choice → executor argument. - [ ] No exposed option has exactly one valid value, and no two `models.ts` variants pass an identical options object. - [ ] The React Hook utilizes `useModel` and properly returns progress, ready state, errors, and task execution bindings. +- [ ] The pipeline has a suite under `__tests__/tasks/` covering schema acceptance and rejection, postprocessing, every option, and full disposal — see the [Add API Tests skill](../add-api-tests/SKILL.md). diff --git a/.agents/skills/core-guidelines/SKILL.md b/.agents/skills/core-guidelines/SKILL.md index 2510c5b475..ad34b5a131 100644 --- a/.agents/skills/core-guidelines/SKILL.md +++ b/.agents/skills/core-guidelines/SKILL.md @@ -72,6 +72,7 @@ Use the following index to locate the specific procedural guides for your task: | **Add a new native operator or C++ binding** | [SKILL.md](../add-native-extension/SKILL.md) | Procedural guide to implementing C++ functions, exposing them via JSI, and writing TypeScript bridge wrappers. | | **Create a task pipeline or hook** | [SKILL.md](../add-task-pipeline/SKILL.md) | Guide to building end-to-end TS pipelines (e.g. object detection) and exposing them via React hooks. | | **Verify, rebuild, or troubleshoot changes** | [SKILL.md](../verify-and-build/SKILL.md) | Workflows for rebuilding TS/C++ and resolving common JSI runtime errors. | +| **Test TypeScript changes** | [SKILL.md](../add-api-tests/SKILL.md) | Covering `src/` with the Jest API suites and their fake native runtime. | | **Validate model constraints & schemas** | [SKILL.md](../model-schema-validation/SKILL.md) | Guide on specifying model specs, dynamic shapes, and runtime constraints for model validation. | | **Maintain or refactor codebase patterns** | [SKILL.md](../skills-maintenance/SKILL.md) | Guide to keeping workspace skills in sync with codebase state to prevent documentation decay. | diff --git a/.agents/skills/skills-maintenance/SKILL.md b/.agents/skills/skills-maintenance/SKILL.md index a8de3b8df9..09a18cf2dc 100644 --- a/.agents/skills/skills-maintenance/SKILL.md +++ b/.agents/skills/skills-maintenance/SKILL.md @@ -26,6 +26,7 @@ Use this guide when you introduce, modify, or deprecate core codebase patterns, - [add-task-pipeline](../add-task-pipeline/SKILL.md) for TypeScript pipeline orchestration, pre-allocation, and lifecycle hooks. - [model-schema-validation](../model-schema-validation/SKILL.md) for schema verification constraints. - [verify-and-build](../verify-and-build/SKILL.md) for compilation and troubleshooting steps. + - [add-api-tests](../add-api-tests/SKILL.md) for the TypeScript test suites and the fake native runtime. 3. **Verify Example Correctness**: - Ensure all code blocks and examples in updated skills compile/work and match actual usage in the repository. diff --git a/.agents/skills/verify-and-build/SKILL.md b/.agents/skills/verify-and-build/SKILL.md index 8fe6018f61..b38c55730f 100644 --- a/.agents/skills/verify-and-build/SKILL.md +++ b/.agents/skills/verify-and-build/SKILL.md @@ -22,6 +22,14 @@ To check types and compile the TypeScript source code: ```bash yarn typecheck ``` +- **Run the TypeScript API Tests**: + ```bash + yarn workspace react-native-executorch test + ``` + _Jest suites over the public `src/` surface — hooks, task pipelines, core + primitives, the fetcher and the model registry — running against a fake + native runtime, so they need no simulator, device or `.pte` file. See the + [Add API Tests skill](../add-api-tests/SKILL.md)._ - **Build Bundles**: ```bash yarn prepare @@ -172,7 +180,7 @@ This project does **not** bundle local `.pte` model files inside the React Nativ ## 🚫 Avoid / Anti-Patterns -- **Do NOT run code without verification:** Do not test TypeScript changes in the app without first running `yarn typecheck` (verify types) and `yarn prepare` (build target bundles). +- **Do NOT run code without verification:** Do not test TypeScript changes in the app without first running `yarn typecheck` (verify types), `yarn workspace react-native-executorch test` (API suites) and `yarn prepare` (build target bundles). - **Do NOT skip native rebuilds after C++ edits:** If any C++ files or config bindings are added/modified, do not attempt to run the app without executing `pod install` (for iOS) or letting Gradle sync (for Android). - **Do NOT run `lint:cpp` with the system `clang-tidy`**: Use the Homebrew LLVM binary: `CLANG_TIDY=$(brew --prefix llvm)/bin/clang-tidy yarn workspace react-native-executorch lint:cpp`. - **Do NOT log complex objects inside worklets:** Avoid passing complex circular objects directly to `console.log()` inside functions annotated with `'worklet';` as it can hang or crash the worklet runtime thread. @@ -185,6 +193,7 @@ This project does **not** bundle local `.pte` model files inside the React Nativ When verifying or compiling your modifications, check that: - [ ] TypeScript typechecking passes without errors (`yarn typecheck`). +- [ ] The TypeScript API tests pass (`yarn workspace react-native-executorch test`), and any new `src/` behavior is covered by them. - [ ] Bundles compile successfully (`yarn prepare`). - [ ] `pod install` has been run inside `apps//ios/` after any native C++ edits. - [ ] `lint:cpp` passes cleanly: `CLANG_TIDY=$(brew --prefix llvm)/bin/clang-tidy yarn workspace react-native-executorch lint:cpp`. diff --git a/.cspell-wordlist.txt b/.cspell-wordlist.txt index 964521ab13..ba7b586b34 100644 --- a/.cspell-wordlist.txt +++ b/.cspell-wordlist.txt @@ -314,3 +314,10 @@ Partitioner denoised ttfa TTFA +letterboxed +macrotask +microtask +microtasks +unbatched +sdcard +dontMock diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bc595841d3..323dc02328 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,3 +39,21 @@ jobs: - name: Build all packages run: yarn workspaces foreach --all --exclude react-native-executorch-bare-resource-fetcher --exclude react-native-executorch-expo-resource-fetcher --exclude react-native-executorch-webrtc --topological-dev run prepare + + api-tests: + name: TypeScript API tests + runs-on: ubuntu-latest + # A full run takes a few seconds; this only has to catch a hang. + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup + uses: ./.github/actions/setup + + # No native libraries, no simulator, no `.pte` download — the suites run + # against the fake JSI runtime in `__tests__/support/`, so the setup + # action's TypeScript-only install is all this job needs. + - name: Run TypeScript API tests + run: yarn workspace react-native-executorch test --ci From 4755b3532536dac572b824e3356e686adbffac83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20S=C5=82uszniak?= Date: Mon, 10 Aug 2026 08:29:37 +0200 Subject: [PATCH 7/7] test(ts): extend the registry and surface suites to the TTS pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rebasing onto the supertonic TTS pipeline (#1317) surfaced two gaps the suites themselves reported: - The registry walk assumed every task config names a single `modelPath`. Supertonic assembles four `.pte` files under `modelPaths`, so its category looked empty and its variant group went unchecked. Recognize both shapes, and compare variants on the whole set of files they name. - The export snapshots record the new TTS surface. Additions only — no removal or rename. --- .../api/__snapshots__/apiSurface.test.ts.snap | 13 ++++++++++ .../__tests__/api/modelRegistry.test.ts | 24 +++++++++++++++---- 2 files changed, 33 insertions(+), 4 deletions(-) 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 index 1c694ad61a..dbbbe339b8 100644 --- a/packages/react-native-executorch/__tests__/api/__snapshots__/apiSurface.test.ts.snap +++ b/packages/react-native-executorch/__tests__/api/__snapshots__/apiSurface.test.ts.snap @@ -14,6 +14,7 @@ exports[`public API surface exports one hook per user-facing task 1`] = ` "useStyleTransfer", "useTextEmbedder", "useTextToImage", + "useTextToSpeech", "useTokenizer", "useVoiceActivityDetector", ] @@ -29,6 +30,7 @@ exports[`public API surface matches the recorded contents of each namespace 1`] "IMAGENET1K_LABELS", "IMAGENET_NORM", "PASCAL_VOC_LABELS", + "SUPERTONIC_DEFAULT_VOICE_NAMES", ], "cv": [ "boxes", @@ -62,7 +64,14 @@ exports[`public API surface matches the recorded contents of each namespace 1`] "validateSpec", ], "speech": [ + "SUPERTONIC_SUPPORTED_LANGUAGES", + "cleanText", + "encodeText", "extractFrames", + "formatChunk", + "parseVoiceStyle", + "partition", + "preprocessText", ], } `; @@ -70,6 +79,8 @@ exports[`public API surface matches the recorded contents of each namespace 1`] exports[`public API surface matches the recorded export list 1`] = ` [ "FSMN_VAD_SAMPLE_RATE_HZ", + "SUPERTONIC_SAMPLE_RATE", + "SUPERTONIC_SUPPORTED_LANGUAGES", "WHISPER_LANGUAGES", "WHISPER_SAMPLE_RATE_HZ", "constants", @@ -82,6 +93,7 @@ exports[`public API surface matches the recorded export list 1`] = ` "createSdxsTextToImage", "createSemanticSegmenter", "createStyleTransfer", + "createSupertonicTextToSpeech", "createTextEmbedder", "createTokenizer", "createWhisperSpeechToText", @@ -110,6 +122,7 @@ exports[`public API surface matches the recorded export list 1`] = ` "useStyleTransfer", "useTextEmbedder", "useTextToImage", + "useTextToSpeech", "useTokenizer", "useVoiceActivityDetector", "wrapAsync", diff --git a/packages/react-native-executorch/__tests__/api/modelRegistry.test.ts b/packages/react-native-executorch/__tests__/api/modelRegistry.test.ts index 4fd221bd8e..1d40b1cb97 100644 --- a/packages/react-native-executorch/__tests__/api/modelRegistry.test.ts +++ b/packages/react-native-executorch/__tests__/api/modelRegistry.test.ts @@ -20,10 +20,26 @@ const isObject = (value: unknown): value is Node => typeof value === 'object' && value !== null && !Array.isArray(value); /** - * A leaf that a `create` factory could be handed: it names a model file. + * A leaf that a `create` factory could be handed: it names its model + * file, or — for a pipeline assembled from several `.pte` files, like + * Supertonic TTS — the record of them. */ const isConfig = (value: unknown): value is Node => - isObject(value) && typeof value.modelPath === 'string'; + isObject(value) && + (typeof value.modelPath === 'string' || + (isObject(value.modelPaths) && + Object.values(value.modelPaths).every((path) => typeof path === 'string'))); + +/** + * The model file(s) a config names, as one comparable identity. + */ +const modelPathsOf = (config: Node): string => + typeof config.modelPath === 'string' + ? config.modelPath + : Object.values(config.modelPaths as Node) + .map(String) + .sort() + .join('|'); /** * A backend variant key, e.g. `XNNPACK_FP32`. Size keys (`TINY`) look the same. @@ -170,10 +186,10 @@ describe('models registry — structure', () => { expect(variants).toContain(defaults); }); - it.each(allGroups)('$label gives every variant a distinct model path', ({ group }) => { + it.each(allGroups)('$label gives every variant distinct model files', ({ group }) => { const paths = Object.entries(group) .filter(([key, value]) => isUpperKey(key) && isConfig(value)) - .map(([, value]) => (value as Node).modelPath); + .map(([, value]) => modelPathsOf(value as Node)); expect(new Set(paths).size).toBe(paths.length); });