From 177d58b4fca65f8fbd72b151fee53b6ebbe7b0f1 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/8] 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 +- 15 files changed, 1687 insertions(+), 1 deletion(-) 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 94b518b535..9400740e6f 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -73,6 +73,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 a5b62933a2..9f42f1b735 100644 --- a/packages/react-native-executorch/package.json +++ b/packages/react-native-executorch/package.json @@ -74,6 +74,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", @@ -126,13 +127,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.10.3", + "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"] } From 76ed3017d71ba2e853915db6b671782235b3b403 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/8] 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 244c56e337a671ea5dbfff3e68a620b1f4f26410 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/8] 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 6300aecb316dbeec91608c7058f8094debf03dc4 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/8] 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 277fbe06a2a3eb9575e48435491c4afed71be5ea 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/8] 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/core-guidelines/SKILL.md | 1 + .agents/skills/skills-maintenance/SKILL.md | 1 + .agents/skills/verify-and-build/SKILL.md | 11 +- .cspell-wordlist.txt | 6 + .github/workflows/ci.yml | 18 +++ 7 files changed, 173 insertions(+), 1 deletion(-) create mode 100644 .agents/skills/add-api-tests/SKILL.md diff --git a/.agents/skills/README.md b/.agents/skills/README.md index a1cc363ff4..486abd3ff8 100644 --- a/.agents/skills/README.md +++ b/.agents/skills/README.md @@ -9,5 +9,6 @@ This directory contains specialized skills (recipes) to guide contributors and A - [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. - [Error Handling](./error-handling/SKILL.md) — Error codes, throwing across worklet and JSI boundaries, and catching. +- [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/core-guidelines/SKILL.md b/.agents/skills/core-guidelines/SKILL.md index 8a6473f4b4..45d8bc0b31 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. | | **Throw, catch, or classify an error** | [SKILL.md](../error-handling/SKILL.md) | The error code set, `RnExecuTorchError`, C++ `RnExecuTorchException`/`guarded`, and adding a code. | | **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 2db33c5888..4455a0bb97 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 @@ -174,7 +182,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 trust a green macOS-only syntax check for platform-conditional C++:** see the clang-tidy notes above. @@ -188,6 +196,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 f6b8c058be..839869ed66 100644 --- a/.cspell-wordlist.txt +++ b/.cspell-wordlist.txt @@ -334,3 +334,9 @@ binarizes unshrunk DEEPSEEK LLMKV +macrotask +microtask +microtasks +unbatched +sdcard +dontMock diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c92ef035a4..c2d6d56994 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -122,3 +122,21 @@ jobs: - name: Run C++ unit tests run: scripts/run-native-tests.sh + + 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 63c3cac80e07bcd2bb1815eb88c7ffc4215ae61a 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 6/8] 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); }); From dfe641e9402fb2d145dab4fb322a09470717046f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20S=C5=82uszniak?= Date: Wed, 26 Aug 2026 20:44:03 +0200 Subject: [PATCH 7/8] test(ts): follow the rewrite branch's API changes Adapts the suites to what landed on `rne-rewrite` since they were written: hooks report absence as `undefined` rather than `null`, aborts surface as a coded `DOWNLOAD_ABORTED` rather than an `AbortError` class, `schema.constr` is now `schema.constraint` with `equality` in place of `eq`, the CV ops moved to singular module names, `bool` joined `DType`, and the iOS download path reads its status through blob-util's `stateChange`, which the mock now serves. Two registry conventions were restated rather than relaxed. Variant groups name their default with an explicit `DEFAULT` key instead of spreading it, and a default may now live further down the tree (a YOLO26 family defaults through a scale and an input size), so the rule is that the default has to be a config the group actually offers. Model files are still required to declare their backend and precision, but Kokoro nests a variant folder under the backend one and publishes its grapheme-to-phoneme models per language, so the backend is looked for anywhere in the path and the untagged files are pinned to that one shape rather than left to opt out silently. The three source fixes the suites originally carried are all upstream already, so the commit that made them is gone. --- .../api/__snapshots__/apiSurface.test.ts.snap | 86 +- .../__tests__/api/apiSurface.test.ts | 11 +- .../__tests__/api/modelRegistry.test.ts | 117 +- .../__tests__/core/schema.test.ts | 41 +- .../__tests__/extensions/ops.test.ts | 4 +- .../__tests__/fetcher/download.test.ts | 18 +- .../__tests__/hooks/taskHooks.test.ts | 4 +- .../__tests__/hooks/useModel.test.ts | 38 +- .../hooks/useResourceDownload.test.ts | 2 +- .../__tests__/support/blobUtilMock.ts | 14 + .../__tests__/support/fakeTensor.ts | 2 + .../__tests__/tasks/embedding.test.ts | 4 +- yarn.lock | 1061 ++++++++++++++++- 13 files changed, 1255 insertions(+), 147 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 dbbbe339b8..7318448638 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 @@ -6,8 +6,11 @@ exports[`public API surface exports one hook per user-facing task 1`] = ` "useImageEmbedder", "useInstanceSegmenter", "useKeypointDetector", + "useLLMChatSession", "useModel", "useObjectDetector", + "useOpticalCharacterRecognizer", + "usePrivacyFilter", "useResourceDownload", "useSemanticSegmenter", "useSpeechToText", @@ -22,31 +25,53 @@ exports[`public API surface exports one hook per user-facing task 1`] = ` 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", - "SUPERTONIC_DEFAULT_VOICE_NAMES", - ], "cv": [ - "boxes", - "image", - "points", + "FORMAT_CHANNELS", + "FORMAT_CONVERSION", + "applyColormap", + "boundingBoxOfPoints", + "createImagePreprocessor", + "cvtColor", + "decodeBox", + "distance", + "extractDbnetTextQuads", + "interpolatePoint", + "nms", + "normalize", + "orderQuad", + "quadSize", + "rectifyQuad", + "resize", + "restrictToBox", + "scaleBox", + "scalePoint", + "scaleQuad", + "toChannelsFirst", + "toChannelsLast", + ], + "llm": [ + "createChatPreprocessor", + "createLLMRunner", + "parseTokenizerConfig", ], "math": [ "argmax", + "gather", "mulberry32", "randomNormal", + "repeatInterleave", "sigmoid", "softmax", "threshold", ], "nlp": [ + "buildGrammar", + "computeCharOffsets", + "extractSpans", + "labelEntityType", "loadTokenizer", + "piiSegments", + "viterbiDecode", ], "schema": [ "ConstantDim", @@ -55,7 +80,8 @@ exports[`public API surface matches the recorded contents of each namespace 1`] "RangeDim", "StaticDim", "SymbolicTensor", - "constr", + "bool", + "constraint", "f32", "i32", "i64", @@ -64,32 +90,55 @@ exports[`public API surface matches the recorded contents of each namespace 1`] "validateSpec", ], "speech": [ + "KOKORO_PAUSE_MS", + "KOKORO_TICKS_PER_DURATION", + "KOKORO_VOICE_REF_SIZE", "SUPERTONIC_SUPPORTED_LANGUAGES", "cleanText", + "createPhonemizer", "encodeText", "extractFrames", "formatChunk", + "parseVoice", "parseVoiceStyle", "partition", "preprocessText", + "scaleDurations", + "stripAudio", + "tokenize", ], } `; exports[`public API surface matches the recorded export list 1`] = ` [ + "BLAZEFACE_LANDMARKS", + "COCO_CLASSES", + "COCO_CLASSES_YOLO", + "COCO_LANDMARKS", "FSMN_VAD_SAMPLE_RATE_HZ", + "IMAGENET1K_LABELS", + "IMAGENET_NORM", + "KOKORO_SAMPLE_RATE", + "PASCAL_VOC_LABELS", + "PRIVACY_FILTER_NEMOTRON_LABELS", + "PRIVACY_FILTER_OPENAI_LABELS", + "RnExecuTorchError", + "SUPERTONIC_DEFAULT_VOICE_NAMES", "SUPERTONIC_SAMPLE_RATE", - "SUPERTONIC_SUPPORTED_LANGUAGES", + "VALID_ERROR_CODES", "WHISPER_LANGUAGES", "WHISPER_SAMPLE_RATE_HZ", - "constants", "createClassifier", "createFsmnVoiceActivityDetector", "createImageEmbedder", "createInstanceSegmenter", "createKeypointDetector", + "createKokoroTextToSpeech", + "createLLMChatSession", "createObjectDetector", + "createPaddleOcr", + "createPrivacyFilter", "createSdxsTextToImage", "createSemanticSegmenter", "createStyleTransfer", @@ -102,6 +151,8 @@ exports[`public API surface matches the recorded export list 1`] = ` "download", "getRegisteredBackends", "inspectModel", + "isRnExecuTorchError", + "llm", "loadModel", "math", "models", @@ -114,8 +165,11 @@ exports[`public API surface matches the recorded export list 1`] = ` "useImageEmbedder", "useInstanceSegmenter", "useKeypointDetector", + "useLLMChatSession", "useModel", "useObjectDetector", + "useOpticalCharacterRecognizer", + "usePrivacyFilter", "useResourceDownload", "useSemanticSegmenter", "useSpeechToText", diff --git a/packages/react-native-executorch/__tests__/api/apiSurface.test.ts b/packages/react-native-executorch/__tests__/api/apiSurface.test.ts index 73e6686637..6f810da14f 100644 --- a/packages/react-native-executorch/__tests__/api/apiSurface.test.ts +++ b/packages/react-native-executorch/__tests__/api/apiSurface.test.ts @@ -13,7 +13,7 @@ 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; +const NAMESPACES = ['cv', 'llm', 'math', 'models', 'nlp', 'schema', 'speech'] as const; describe('public API surface', () => { it('matches the recorded export list', () => { @@ -44,10 +44,19 @@ describe('public API surface', () => { // `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']); + // `useOpticalCharacterRecognizer` names the task while its factory names + // the model (`createPaddleOcr`), so the two cannot be matched by name. + const namedAfterTheModel: Record = { + useOpticalCharacterRecognizer: 'createPaddleOcr', + }; const factories = new Set(Object.keys(api).filter((key) => key.startsWith('create'))); + // Every hand-listed pair still has to name a factory that exists. + for (const factory of Object.values(namedAfterTheModel)) expect(factories).toContain(factory); + const unpaired = Object.keys(api) .filter((key) => key.startsWith('use') && !generic.has(key)) + .filter((key) => !(key in namedAfterTheModel)) .filter((hook) => { const task = hook.slice('use'.length); return ![...factories].some((factory) => factory.slice('create'.length).includes(task)); diff --git a/packages/react-native-executorch/__tests__/api/modelRegistry.test.ts b/packages/react-native-executorch/__tests__/api/modelRegistry.test.ts index 1d40b1cb97..00b5624d66 100644 --- a/packages/react-native-executorch/__tests__/api/modelRegistry.test.ts +++ b/packages/react-native-executorch/__tests__/api/modelRegistry.test.ts @@ -77,23 +77,46 @@ function configs(node: unknown, path: string[] = []): { label: string; config: N } /** - * Every group that both spreads a default config and lists named variants — - * the `{ ...X_FP32, XNNPACK_FP32: X_FP32, COREML_FP16: X_FP16 }` shape. + * Every group that names a default alongside the variants it can point at — + * the `{ DEFAULT: X_FP32, XNNPACK_FP32: X_FP32, COREML_FP16: X_FP16 }` shape. + * + * A group's `DEFAULT` does not have to be one of its immediate siblings: a + * family like `objectDetection.YOLO26` defaults to a config that lives two + * levels down, under a scale and then an input size. */ 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 here = isConfig(node.DEFAULT) ? [{ label: path.join('.'), group: node }] : []; const nested = Object.entries(node) - .filter(([, value]) => isObject(value)) + .filter(([key, value]) => key !== 'DEFAULT' && isObject(value)) .flatMap(([key, value]) => variantGroups(value, [...path, key])); return [...here, ...nested]; } +/** + * The named backend variants a group lists directly, excluding its `DEFAULT` + * alias and any nested scale/size families. + */ +const namedVariants = (group: Node): Node[] => + Object.entries(group) + .filter(([key, value]) => key !== 'DEFAULT' && isUpperKey(key) && isConfig(value)) + .map(([, value]) => value as Node); + +const BACKENDS = /^(xnnpack|coreml|mlx|qnn|vulkan)$/; +// `spinquant` is a quantization recipe rather than a plain precision, but it is +// what the published Llama builds are named after. +const PRECISIONS = /^(fp32|fp16|bf16|int8|int4|8da4w|4w|dynamic|spinquant)$/; + +const basename = (url: string) => url.split('/').pop()!.replace('.pte', ''); + +/** The backend a `.pte` filename declares, or `undefined` when it declares none. */ +const backendOf = (url: string): string | undefined => { + const backend = basename(url).split('_').at(-2) ?? ''; + return BACKENDS.test(backend) ? backend : undefined; +}; + const urls = urlLeaves(models); +const pteUrls = urls.filter(([, url]) => url.endsWith('.pte')); const allConfigs = configs(models); const allGroups = variantGroups(models); @@ -117,29 +140,38 @@ describe('models registry — URLs', () => { 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()}`); + it('names every backend-tagged .pte after the modelname_backend_precision contract', () => { + const offenders = pteUrls + .filter(([, url]) => backendOf(url) !== undefined) + .filter(([, url]) => !PRECISIONS.test(basename(url).split('_').at(-1)!)) + .map(([path, url]) => `${path}: ${basename(url)}`); + + expect(offenders).toEqual([]); + }); + + // The rule above only bites on files that carry a backend at all, so the + // exceptions are pinned here rather than left to silently opt out: a new + // untagged `.pte` has to be added deliberately. + it('leaves only the known exceptions untagged', () => { + // Kokoro's grapheme-to-phoneme models are published per language rather + // than per backend, under `phonemizer//`. + const offenders = pteUrls + .filter(([, url]) => backendOf(url) === undefined) + .filter(([, url]) => !/\/phonemizer\/[a-z-]+\/phonemizer_[a-z_]+\.pte$/.test(url)) + .map(([path, url]) => `${path}: ${url}`); expect(offenders).toEqual([]); }); - it('stores every .pte in a folder matching its backend suffix', () => { - const offenders = urls - .filter(([, url]) => url.endsWith('.pte')) + it('stores every backend-tagged .pte under a folder naming its backend', () => { + const offenders = pteUrls + .filter(([, url]) => backendOf(url) !== undefined) .filter(([, url]) => { - const segments = url.split('/'); - return segments.at(-2) !== segments.at(-1)!.replace('.pte', '').split('_').at(-2); + // Kokoro nests a variant folder below the backend one + // (`xnnpack/polish/…`), so the backend is looked for anywhere in the + // path rather than only in the segment above the file. + const segments = url.split('/').slice(0, -1); + return !segments.includes(backendOf(url)!); }) .map(([path, url]) => `${path}: ${url}`); @@ -172,27 +204,22 @@ describe('models registry — structure', () => { 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 defaults to a config it actually offers', ({ group }) => { + // The default has to be reachable through the group's own tree, otherwise + // `models.x.Y` and every `models.x.Y.` silently disagree. Compare + // the config fields only: a variant may carry further nested groups (a size + // family, say) that the default alias never includes. + const offered = configs(group).map(({ config }) => JSON.stringify(configPart(config))); + expect(offered).toContain(JSON.stringify(configPart(group.DEFAULT as Node))); }); - 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]) => modelPathsOf(value as Node)); - - expect(new Set(paths).size).toBe(paths.length); - }); + it.each(allGroups.filter(({ group }) => namedVariants(group).length > 0))( + '$label gives every named variant distinct model files', + ({ group }) => { + const paths = namedVariants(group).map(modelPathsOf); + expect(new Set(paths).size).toBe(paths.length); + } + ); it('uses distinct entry names within each category', () => { for (const group of Object.values(models)) { diff --git a/packages/react-native-executorch/__tests__/core/schema.test.ts b/packages/react-native-executorch/__tests__/core/schema.test.ts index 24036a52ba..e2fa107441 100644 --- a/packages/react-native-executorch/__tests__/core/schema.test.ts +++ b/packages/react-native-executorch/__tests__/core/schema.test.ts @@ -4,7 +4,7 @@ import { EnumDim, RangeDim, StaticDim, - constr, + constraint, f32, i64, method, @@ -294,7 +294,7 @@ describe('validateSpec — runtime constraints', () => { ) ); - const equality = [constr.eq(inputDim(0, 1), inputDim(1, 1))]; + const equality = [constraint.equality(inputDim(0, 1), inputDim(1, 1))]; const allowed = (constraints: Parameters[3]) => ({ only: method( @@ -322,12 +322,12 @@ describe('validateSpec — runtime constraints', () => { }); it('matches equality constraints regardless of the order of their dimensions', () => { - const reversed = [constr.eq(inputDim(1, 1), inputDim(0, 1))]; + const reversed = [constraint.equality(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))]; + const elsewhere = [constraint.equality(inputDim(0, 1), outputDim(0, 1))]; expect(() => validateSpec(withConstraint(elsewhere), allowed(equality))).toThrow( /Not declared by the exported model spec/ ); @@ -339,7 +339,7 @@ describe('validateSpec — runtime constraints', () => { 'forward', [f32(RangeDim(1, 64))], [f32(RangeDim(1, 64))], - [constr.linear(outputDim(0, 0), inputDim(0, 0), 2, 1)] + [constraint.linear(outputDim(0, 0), inputDim(0, 0), 2, 1)] ) ); @@ -348,7 +348,7 @@ describe('validateSpec — runtime constraints', () => { 'forward', [f32(DynamicDim('L'))], [f32(DynamicDim('L'))], - [constr.linear(outputDim(0, 0), inputDim(0, 0), 2, 1)] + [constraint.linear(outputDim(0, 0), inputDim(0, 0), 2, 1)] ), }; const different = { @@ -356,7 +356,7 @@ describe('validateSpec — runtime constraints', () => { 'forward', [f32(DynamicDim('L'))], [f32(DynamicDim('L'))], - [constr.linear(outputDim(0, 0), inputDim(0, 0), 2, 0)] + [constraint.linear(outputDim(0, 0), inputDim(0, 0), 2, 0)] ), }; @@ -365,7 +365,7 @@ describe('validateSpec — runtime constraints', () => { }); it('defaults the linear intercept to zero', () => { - expect(constr.linear(outputDim(0, 0), inputDim(0, 0), 2).coefficients).toEqual([2, 0]); + expect(constraint.linear(outputDim(0, 0), inputDim(0, 0), 2).coefficients).toEqual([2, 0]); }); }); @@ -375,7 +375,7 @@ describe('validateSpec — authoring errors', () => { 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))]), + only: method('forward', [f32(4)], [f32(4)], [constraint.equality(inputDim(0, 0))]), }) ).toThrow(/at least two dimensions/); }); @@ -387,7 +387,7 @@ describe('validateSpec — authoring errors', () => { 'forward', [f32(4)], [f32(4)], - [constr.linear(outputDim(0, 0), inputDim(0, 0), 1.5)] + [constraint.linear(outputDim(0, 0), inputDim(0, 0), 1.5)] ), }) ).toThrow(/Coefficients must be integers/); @@ -396,7 +396,12 @@ describe('validateSpec — authoring errors', () => { 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))]), + only: method( + 'forward', + [f32(4)], + [f32(4)], + [constraint.equality(inputDim(3, 0), outputDim(0, 0))] + ), }) ).toThrow(/tensor index out of range/); }); @@ -404,7 +409,12 @@ describe('validateSpec — authoring errors', () => { 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))]), + only: method( + 'forward', + [f32(4)], + [f32(4)], + [constraint.equality(inputDim(0, 5), outputDim(0, 0))] + ), }) ).toThrow(/dimension index out of range/); }); @@ -414,7 +424,12 @@ describe('validateSpec — authoring errors', () => { // 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))]), + broken: method( + 'forward', + [f32(4)], + [f32(4)], + [constraint.equality(inputDim(9, 0), outputDim(0, 0))] + ), fine: method('forward', [f32(4)], [f32(4)]), }) ).toThrow(/tensor index out of range/); diff --git a/packages/react-native-executorch/__tests__/extensions/ops.test.ts b/packages/react-native-executorch/__tests__/extensions/ops.test.ts index 2c33538141..10ff853364 100644 --- a/packages/react-native-executorch/__tests__/extensions/ops.test.ts +++ b/packages/react-native-executorch/__tests__/extensions/ops.test.ts @@ -8,8 +8,8 @@ * 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 { decodeBox, scaleBox } from '../../src/extensions/cv/ops/box'; +import { scalePoint } from '../../src/extensions/cv/ops/point'; import { FORMAT_CHANNELS, FORMAT_CONVERSION } from '../../src/extensions/cv/ops/image'; describe('decodeBox', () => { diff --git a/packages/react-native-executorch/__tests__/fetcher/download.test.ts b/packages/react-native-executorch/__tests__/fetcher/download.test.ts index f52da37865..fa09cb82ff 100644 --- a/packages/react-native-executorch/__tests__/fetcher/download.test.ts +++ b/packages/react-native-executorch/__tests__/fetcher/download.test.ts @@ -1,4 +1,5 @@ -import { AbortError, download } from '../../src/fetcher/fetcher'; +import { isRnExecuTorchError } from '../../src/core/error'; +import { download } from '../../src/fetcher/fetcher'; import { setTelemetryEnabled } from '../../src/fetcher/telemetry'; import { until } from '../support/async'; import { deferred, fakeFs, fakeNet } from '../support/blobUtilMock'; @@ -205,17 +206,16 @@ describe('download — failure handling', () => { }); describe('download — cancellation', () => { - it('rejects with an AbortError when the signal is already aborted', async () => { + it('rejects with DOWNLOAD_ABORTED 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'); + expect(isRnExecuTorchError(rejection, 'DOWNLOAD_ABORTED')).toBe(true); }); - it('rejects with an AbortError when aborted mid-flight', async () => { + it('rejects with DOWNLOAD_ABORTED when aborted mid-flight', async () => { const gate = deferred(); fakeNet.serve(URL_A, { gate: gate.promise }); @@ -226,7 +226,7 @@ describe('download — cancellation', () => { controller.abort(); gate.resolve(); - expect(await rejection).toBeInstanceOf(AbortError); + expect(isRnExecuTorchError(await rejection, 'DOWNLOAD_ABORTED')).toBe(true); expect(cachedPath('model.pte')).toBeUndefined(); }); @@ -237,7 +237,7 @@ describe('download — cancellation', () => { const rejection = download(URL_A, { signal: controller.signal }).catch((e) => e); controller.abort(); - expect(await rejection).toBeInstanceOf(AbortError); + expect(isRnExecuTorchError(await rejection, 'DOWNLOAD_ABORTED')).toBe(true); expect(fakeNet.countRequests('GET', URL_A)).toBe(0); }); }); @@ -282,7 +282,7 @@ describe('download — concurrent callers', () => { controller.abort(); gate.resolve(); - expect(await leavingRejection).toBeInstanceOf(AbortError); + expect(isRnExecuTorchError(await leavingRejection, 'DOWNLOAD_ABORTED')).toBe(true); expect(await staying).toBe(cachedPath('model.pte')); expect(fakeNet.countRequests('GET', URL_A)).toBe(1); }); @@ -296,7 +296,7 @@ describe('download — concurrent callers', () => { await untilFetching(URL_A); controller.abort(); gate.resolve(); - expect(await abandoned).toBeInstanceOf(AbortError); + expect(isRnExecuTorchError(await abandoned, 'DOWNLOAD_ABORTED')).toBe(true); fakeNet.serve(URL_A, { body: 'second attempt' }); const path = await download(URL_A); diff --git a/packages/react-native-executorch/__tests__/hooks/taskHooks.test.ts b/packages/react-native-executorch/__tests__/hooks/taskHooks.test.ts index d3034a2306..beae69f524 100644 --- a/packages/react-native-executorch/__tests__/hooks/taskHooks.test.ts +++ b/packages/react-native-executorch/__tests__/hooks/taskHooks.test.ts @@ -63,7 +63,7 @@ describe('useClassifier', () => { expect(result.current.isReady).toBe(false); expect(result.current.classify).toBeUndefined(); - expect(result.current.error).toBeNull(); + expect(result.current.error).toBeUndefined(); gate.resolve(); await waitFor(() => expect(result.current.isReady).toBe(true)); @@ -188,7 +188,7 @@ describe('use hooks — shared contract', () => { expect(result.current).toMatchObject({ isReady: expect.any(Boolean), - error: null, + error: undefined, 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 index 74015e829a..5f1b7c45f3 100644 --- a/packages/react-native-executorch/__tests__/hooks/useModel.test.ts +++ b/packages/react-native-executorch/__tests__/hooks/useModel.test.ts @@ -17,28 +17,28 @@ const factoryOf = (instances: 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 }); + const { result } = await renderHook(() => useModel(factoryOf([]), undefined)); + expect(result.current).toEqual({ model: undefined, error: undefined }); }); 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()); + await waitFor(() => expect(result.current.model).toBeDefined()); expect(result.current.model).toBe(instances[0]); }); - it('does not create anything for a null config', async () => { + it('does not create anything for an undefined config', async () => { const factory = factoryOf([]); - await renderHook(() => useModel(factory, null)); + await renderHook(() => useModel(factory, undefined)); 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 waitFor(() => expect(result.current.model).toBeDefined()); await unmount(); @@ -52,7 +52,7 @@ describe('useModel', () => { ({ config }: { config: { id: string } }) => useModel(factory, config), { initialProps: { config: { id: 'a' } } } ); - await waitFor(() => expect(result.current.model).not.toBeNull()); + await waitFor(() => expect(result.current.model).toBeDefined()); await rerender({ config: { id: 'b' } }); await waitFor(() => expect(result.current.model?.id).toBe('b')); @@ -68,7 +68,7 @@ describe('useModel', () => { ({ config }: { config: { id: string } }) => useModel(factory, config), { initialProps: { config: { id: 'a' } } } ); - await waitFor(() => expect(result.current.model).not.toBeNull()); + await waitFor(() => expect(result.current.model).toBeDefined()); // A new object with identical contents — the common case for an inline // config literal being re-created on every render. @@ -104,9 +104,9 @@ describe('useModel', () => { const { result } = await renderHook(() => useModel(factory, { id: 'a' })); - await waitFor(() => expect(result.current.error).not.toBeNull()); + await waitFor(() => expect(result.current.error).toBeDefined()); expect(result.current.error?.message).toBe('spec mismatch'); - expect(result.current.model).toBeNull(); + expect(result.current.model).toBeUndefined(); }); it('wraps a non-Error rejection in an Error', async () => { @@ -131,27 +131,27 @@ describe('useModel', () => { ({ config }: { config: { id: string } }) => useModel(factory, config), { initialProps: { config: { id: 'bad' } } } ); - await waitFor(() => expect(result.current.error).not.toBeNull()); + await waitFor(() => expect(result.current.error).toBeDefined()); shouldFail = false; await rerender({ config: { id: 'good' } }); - await waitFor(() => expect(result.current.model).not.toBeNull()); - expect(result.current.error).toBeNull(); + await waitFor(() => expect(result.current.model).toBeDefined()); + expect(result.current.error).toBeUndefined(); }); - it('clears the model when the config becomes null', async () => { + it('clears the model when the config becomes undefined', 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 } } + ({ config }: { config: { id: string } | undefined }) => useModel(factory, config), + { initialProps: { config: { id: 'a' } as { id: string } | undefined } } ); - await waitFor(() => expect(result.current.model).not.toBeNull()); + await waitFor(() => expect(result.current.model).toBeDefined()); - await rerender({ config: null }); + await rerender({ config: undefined }); - await waitFor(() => expect(result.current.model).toBeNull()); + await waitFor(() => expect(result.current.model).toBeUndefined()); 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 index 19716146e4..fafedadaaa 100644 --- a/packages/react-native-executorch/__tests__/hooks/useResourceDownload.test.ts +++ b/packages/react-native-executorch/__tests__/hooks/useResourceDownload.test.ts @@ -29,7 +29,7 @@ describe('useResourceDownload', () => { expect(result.current).toEqual({ resource: undefined, downloadProgress: 0, - downloadError: null, + downloadError: undefined, }); }); diff --git a/packages/react-native-executorch/__tests__/support/blobUtilMock.ts b/packages/react-native-executorch/__tests__/support/blobUtilMock.ts index 33592e05ec..e74009bb8e 100644 --- a/packages/react-native-executorch/__tests__/support/blobUtilMock.ts +++ b/packages/react-native-executorch/__tests__/support/blobUtilMock.ts @@ -171,9 +171,14 @@ export const fakeFetch = async ( // ============================================================================ type ProgressCallback = (received: string, total: string) => void; +type StateChangeCallback = (info: { status?: number }) => void; type FetchTask = Promise<{ info: () => { status: number } }> & { progress: (config: { count?: number }, cb: ProgressCallback) => FetchTask; + // Undocumented in blob-util's typings, but real: it reports the response + // state, so `src/` learns the status as soon as the headers land rather than + // only once the body is complete. See `downloadUrlViaIosStream`. + stateChange: (cb: StateChangeCallback) => FetchTask; cancel: () => void; }; @@ -196,6 +201,7 @@ function startFetch(config: Config, method: string, url: string, headers: Record requests.push({ method, url, headers }); let onProgress: ProgressCallback | undefined; + let onStateChange: StateChangeCallback | undefined; let cancelled = false; const run = async () => { @@ -220,6 +226,10 @@ function startFetch(config: Config, method: string, url: string, headers: Record // Otherwise the server ignores the range and re-sends everything (200). } + // The status is known once the headers are in, which is before any of the + // body arrives and before the gate a test may be holding the response on. + onStateChange?.({ status }); + // 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)); @@ -239,6 +249,10 @@ function startFetch(config: Config, method: string, url: string, headers: Record onProgress = cb; return task; }; + task.stateChange = (cb) => { + onStateChange = cb; + return task; + }; task.cancel = () => { cancelled = true; }; diff --git a/packages/react-native-executorch/__tests__/support/fakeTensor.ts b/packages/react-native-executorch/__tests__/support/fakeTensor.ts index ef0dc9f07c..b70ddcfc79 100644 --- a/packages/react-native-executorch/__tests__/support/fakeTensor.ts +++ b/packages/react-native-executorch/__tests__/support/fakeTensor.ts @@ -15,6 +15,7 @@ const STORAGE: Record Storage> = { uint8: Uint8Array, int32: Int32Array, int64: BigInt64Array, + bool: Uint8Array, }; const BYTES_PER_ELEMENT: Record = { @@ -22,6 +23,7 @@ const BYTES_PER_ELEMENT: Record = { uint8: 1, int32: 4, int64: 8, + bool: 1, }; /** Live tensors, keyed by their id, so leaks can be reported with their shape. */ diff --git a/packages/react-native-executorch/__tests__/tasks/embedding.test.ts b/packages/react-native-executorch/__tests__/tasks/embedding.test.ts index 234c8728cc..90c46db8eb 100644 --- a/packages/react-native-executorch/__tests__/tasks/embedding.test.ts +++ b/packages/react-native-executorch/__tests__/tasks/embedding.test.ts @@ -1,4 +1,4 @@ -import { RangeDim, constr, f32, i64, method } from '../../src/core/schema'; +import { RangeDim, constraint, 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'; @@ -63,7 +63,7 @@ describe('createTextEmbedder', () => { const VOCAB = ['', 'hello', 'world', 'query:', 'document:']; const SEQUENCE = RangeDim(1, 8); const EQUAL_LENGTHS = [ - constr.eq( + constraint.equality( { paramSide: 'input', tensorIdx: 0, dimIdx: 1 }, { paramSide: 'input', tensorIdx: 1, dimIdx: 1 } ), diff --git a/yarn.lock b/yarn.lock index e45de3eec1..1b707d43ac 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" @@ -3212,6 +3253,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" @@ -3221,6 +3324,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" @@ -3233,6 +3343,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" @@ -3247,6 +3376,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" @@ -3256,6 +3450,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" @@ -3337,7 +3566,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: @@ -4622,6 +4851,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" @@ -4808,6 +5044,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" @@ -4890,7 +5146,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 @@ -4915,6 +5171,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" @@ -4956,6 +5222,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" @@ -5390,7 +5665,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 @@ -6229,6 +6504,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" @@ -6319,6 +6601,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" @@ -6377,6 +6666,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" @@ -6594,6 +6897,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" @@ -6859,7 +7179,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: @@ -6903,6 +7223,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" @@ -6910,7 +7242,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 @@ -7020,6 +7352,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" @@ -7036,6 +7375,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" @@ -7157,6 +7503,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" @@ -7771,6 +8124,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.17": version: 56.0.17 resolution: "expo-asset@npm:56.0.17" @@ -8311,7 +8701,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: @@ -8564,6 +8954,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" @@ -8917,6 +9314,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" @@ -8964,6 +9368,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" @@ -9008,6 +9419,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" @@ -9240,6 +9663,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" @@ -9494,7 +9924,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 @@ -9514,23 +9944,68 @@ __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 - resolution: "jackspeak@npm: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" "@pkgjs/parseargs": "npm:^0.11.0" @@ -9541,6 +10016,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" @@ -9585,6 +10209,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" @@ -9613,6 +10271,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" @@ -9620,6 +10290,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" @@ -9648,6 +10432,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" @@ -9660,6 +10460,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" @@ -10095,6 +10914,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" @@ -11538,7 +12366,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: @@ -11711,7 +12539,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: @@ -11778,7 +12606,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: @@ -12032,6 +12860,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" @@ -12113,7 +12950,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: @@ -12154,7 +13003,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: @@ -12192,6 +13041,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" @@ -12253,6 +13109,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" @@ -12260,13 +13130,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" @@ -12411,13 +13274,17 @@ __metadata: "@huggingface/jinja": "npm:^0.5.9" "@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.10.3" + test-renderer: "npm:^1.2.0" typescript: "npm:~5.9.2" peerDependencies: react: "*" @@ -12764,6 +13631,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" @@ -12952,6 +13830,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" @@ -12973,6 +13860,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" @@ -13002,6 +13910,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" @@ -13144,7 +14066,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 @@ -13370,7 +14292,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 @@ -13484,6 +14406,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" @@ -13665,6 +14597,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" @@ -13790,6 +14732,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" @@ -13945,6 +14894,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" @@ -14381,6 +15342,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" @@ -14729,6 +15701,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 f08af3391d62114ccab91eb0246193bb3ec2c871 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20S=C5=82uszniak?= Date: Wed, 26 Aug 2026 20:58:08 +0200 Subject: [PATCH 8/8] test(ts): cover the pipelines that landed while this was open `rne-rewrite` gained the privacy filter, the LLM chat session, Kokoro TTS, PaddleOCR and the coded error type since these suites were written, and none of them had a suite. Only the weights are out of scope, so most of this runs end to end. The privacy filter's logits come from a scripted `execute`, and everything above them is driven for real: the BIOES grammar, the Viterbi decode, the sliding window and its overlap policy, and the static-versus-dynamic export handling. The chat session runs against a runner whose KV cache is a token position, which is enough to check that a second turn prefills only what is new, that a tool loop feeds results back, and that a failed turn leaves the session usable. Kokoro gets its argument validation, chunking and streaming; OCR gets its quad decode, CTC collapse, confidence filter and reading order over a probability map the test paints. That needed the fake runtime to grow the parts those pipelines reach: `llm.createLLMRunner`, `speech.createPhonemizer`, `math.gather`, `cv.extractDbnetTextQuads`, `cv.rectifyQuad` and `fs.readFile`. The two CV decoders implement their documented contract over axis-aligned regions rather than tracing contours and warping perspective, which is the same answer for the rectangles a pipeline test draws and keeps every threshold the caller passes load-bearing. The leak check now also covers LLM runners and phonemizers, so the pipelines that own them get disposal coverage the same way. --- .agents/skills/add-api-tests/SKILL.md | 16 +- .cspell-wordlist.txt | 7 + .../__tests__/README.md | 30 +- .../__tests__/api/constants.test.ts | 74 ++++ .../core/__snapshots__/error.test.ts.snap | 16 + .../__tests__/core/error.test.ts | 151 +++++++ .../__tests__/support/blobUtilMock.ts | 25 ++ .../__tests__/support/fakeJsi.ts | 183 +++++++- .../__tests__/support/fakeOps.ts | 205 +++++++++ .../__tests__/support/setup.ts | 14 +- .../tasks/kokoroTextToSpeech.test.ts | 399 +++++++++++++++++ .../__tests__/tasks/llmChatSession.test.ts | 410 ++++++++++++++++++ .../__tests__/tasks/paddleOcr.test.ts | 319 ++++++++++++++ .../__tests__/tasks/privacyFilter.test.ts | 310 +++++++++++++ 14 files changed, 2145 insertions(+), 14 deletions(-) create mode 100644 packages/react-native-executorch/__tests__/core/__snapshots__/error.test.ts.snap create mode 100644 packages/react-native-executorch/__tests__/core/error.test.ts create mode 100644 packages/react-native-executorch/__tests__/tasks/kokoroTextToSpeech.test.ts create mode 100644 packages/react-native-executorch/__tests__/tasks/llmChatSession.test.ts create mode 100644 packages/react-native-executorch/__tests__/tasks/paddleOcr.test.ts create mode 100644 packages/react-native-executorch/__tests__/tasks/privacyFilter.test.ts diff --git a/.agents/skills/add-api-tests/SKILL.md b/.agents/skills/add-api-tests/SKILL.md index 3a8f306c2e..f0f699bcdd 100644 --- a/.agents/skills/add-api-tests/SKILL.md +++ b/.agents/skills/add-api-tests/SKILL.md @@ -53,6 +53,9 @@ Key helpers: | :--- | :--- | | `fakeJsi.registerModel(path, program)` | Make `loadModel(path)` succeed with a given schema and `execute` | | `fakeJsi.registerTokenizer(path, vocabulary)` | Same for `loadTokenizer` | +| `fakeJsi.registerLLMRunner(path, program)` | Same for `createLLMRunner`: a context window and the responses to generate, one per `generate` call | +| `fakePhonemizer.serve(text, phonemes)` | Script the native grapheme-to-phoneme converter | +| `fakeFs.write(path, contents)` | Put a file where a pipeline will read it (a charset, a `tokenizer_config.json`, a voice matrix) | | `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 | @@ -80,6 +83,12 @@ Key helpers: tensors. 7. **Sync/async parity** — `runTaskWorklet(x)` equals `await runTask(x)`. +Only the *weights* are out of scope, not the pipeline that runs on them. Before +settling for schema-acceptance-and-disposal, check what is actually TypeScript: +a decode loop, a sliding window, a chunker, an argument check, a streaming +generator and a disposal path all run fine over a scripted `execute`. Reach for +the minimal treatment only when the assertion would be measuring the fixture. + 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. @@ -126,8 +135,11 @@ When adding or changing code under `src/`, verify that: - [ ] 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). + `modelname_backend_precision.pte`, a folder naming that backend, and a + `DEFAULT` that is one of the configs the group offers). +- [ ] A new error code is in `VALID_ERROR_CODES`, so `isRnExecuTorchError` does + not reject the library's own error — `__tests__/core/error.test.ts` reads + every code `src/` raises out of the source and checks it is listed. - [ ] 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 diff --git a/.cspell-wordlist.txt b/.cspell-wordlist.txt index 839869ed66..c75488d219 100644 --- a/.cspell-wordlist.txt +++ b/.cspell-wordlist.txt @@ -340,3 +340,10 @@ microtasks unbatched sdcard dontMock +BIES +binarize +binarized +unclip +phonemizations +phonemizes +həlˈoʊ diff --git a/packages/react-native-executorch/__tests__/README.md b/packages/react-native-executorch/__tests__/README.md index df0781f487..3fcc6e9f9c 100644 --- a/packages/react-native-executorch/__tests__/README.md +++ b/packages/react-native-executorch/__tests__/README.md @@ -29,9 +29,9 @@ 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/fakeOps.ts` | JS implementations of the `math`, `cv` and `speech` operators, plus the phonemizer host object | +| `support/fakeJsi.ts` | `createTensor`, `loadModel`, `loadTokenizer`, `createLLMRunner`, and the resource trackers | +| `support/blobUtilMock.ts` | In-memory filesystem plus a programmable server (status, body, `Range` support, `stateChange`, 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: @@ -71,9 +71,9 @@ misleading leak error. A test that means to leak calls `allowNativeLeaks()`. | Path | Contents | | --- | --- | -| `core/` | `tensor`, `model`, `runtime`, and the `schema` spec matcher | +| `core/` | `tensor`, `model`, `runtime`, the coded `error` type, 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 | +| `tasks/` | One suite per task pipeline, plus the shared construction-failure behavior. `remainingTasks.ts` holds the pipelines that only get schema acceptance and disposal | | `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 | @@ -85,11 +85,21 @@ misleading leak error. A test that means to leak calls `allowNativeLeaks()`. `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 weights.** 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 line is drawn per pipeline rather than per suite, because it falls in a +different place for each. Kokoro's waveform is weights, but its chunking, +argument validation and streaming are not; the privacy filter's logits are +weights, but the BIOES decode and the sliding window over them are pure +TypeScript and are driven end to end; the LLM's generation belongs to the +native runner, but the chat session's history, KV cache bookkeeping and +tool-calling loop are covered against a scripted one; PaddleOCR's probability +map is weights, but the quad decode, CTC collapse and reading order run over a +map the test paints. **The thread hop.** Worklets run inline here, so serialization onto a real worklet runtime is not exercised. The `'worklet'` directive convention that diff --git a/packages/react-native-executorch/__tests__/api/constants.test.ts b/packages/react-native-executorch/__tests__/api/constants.test.ts index 4beb47c73d..b07d32837d 100644 --- a/packages/react-native-executorch/__tests__/api/constants.test.ts +++ b/packages/react-native-executorch/__tests__/api/constants.test.ts @@ -116,6 +116,80 @@ describe('IMAGENET_NORM', () => { }); }); +describe('BIOES privacy-filter label spaces', () => { + const SPACES = { + PRIVACY_FILTER_OPENAI_LABELS: 8, + PRIVACY_FILTER_NEMOTRON_LABELS: 55, + } as const; + + const space = (name: keyof typeof SPACES): readonly string[] => constants[name]; + + it.each(Object.entries(SPACES))( + '%s holds one outside tag plus four per entity', + (name, entities) => { + // The arrays are consumed positionally: the pipeline uses the index as + // the label id and derives the entity type from the tag's suffix, so a + // single missing prefix shifts every id after it. + expect(space(name as keyof typeof SPACES)).toHaveLength(1 + entities * 4); + } + ); + + it.each(Object.keys(SPACES))("%s starts with the outside tag 'O'", (name) => { + expect(space(name as keyof typeof SPACES)[0]).toBe('O'); + }); + + it.each(Object.keys(SPACES))('%s gives every entity all four BIOES prefixes', (name) => { + const labels = space(name as keyof typeof SPACES).slice(1); + const byEntity = new Map(); + for (const label of labels) { + const [prefix, entity] = [label.slice(0, 1), label.slice(2)]; + byEntity.set(entity, [...(byEntity.get(entity) ?? []), prefix]); + } + + const incomplete = [...byEntity.entries()] + .filter(([, prefixes]) => prefixes.join('') !== 'BIES') + .map(([entity, prefixes]) => `${entity}: ${prefixes.join('')}`); + + expect(incomplete).toEqual([]); + }); + + it.each(Object.keys(SPACES))('%s names every label exactly once', (name) => { + const labels = space(name as keyof typeof SPACES); + expect(new Set(labels).size).toBe(labels.length); + }); + + it.each(Object.keys(SPACES))('%s names every entity in snake_case', (name) => { + for (const label of space(name as keyof typeof SPACES).slice(1)) { + expect(label).toMatch(/^[BIES]-[a-z][a-z0-9_]*$/); + } + }); + + it('keeps the two label spaces independent', () => { + // Each array mirrors its own model's `id2label`, so they do NOT share an + // entity vocabulary: the base model prefixes its types with `private_` + // where the larger one does not. Stated here so a future edit does not + // "fix" one array into the other's naming and silently shift its ids. + const nemotron = new Set(constants.PRIVACY_FILTER_NEMOTRON_LABELS); + expect( + constants.PRIVACY_FILTER_OPENAI_LABELS.filter((label) => !nemotron.has(label)) + ).not.toEqual([]); + }); +}); + +describe('SUPERTONIC_DEFAULT_VOICE_NAMES', () => { + it('lists five female and five male voices', () => { + const names = constants.SUPERTONIC_DEFAULT_VOICE_NAMES; + expect(names.filter((name) => name.startsWith('F'))).toHaveLength(5); + expect(names.filter((name) => name.startsWith('M'))).toHaveLength(5); + }); + + it('names every voice uniquely, as a gender letter and an index', () => { + const names = constants.SUPERTONIC_DEFAULT_VOICE_NAMES; + expect(new Set(names).size).toBe(names.length); + for (const name of names) expect(name).toMatch(/^[FM][1-5]$/); + }); +}); + describe('registry ↔ constants alignment', () => { /** Every label array actually referenced by a registry entry. */ const referenced = (function collect(node: unknown): unknown[][] { diff --git a/packages/react-native-executorch/__tests__/core/__snapshots__/error.test.ts.snap b/packages/react-native-executorch/__tests__/core/__snapshots__/error.test.ts.snap new file mode 100644 index 0000000000..9ce049e60d --- /dev/null +++ b/packages/react-native-executorch/__tests__/core/__snapshots__/error.test.ts.snap @@ -0,0 +1,16 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`VALID_ERROR_CODES matches the recorded code list 1`] = ` +[ + "LOAD_FAILED", + "EXECUTION_FAILED", + "SCHEMA_MISMATCH", + "INVALID_ARGUMENT", + "INVALID_STATE", + "RESOURCE_DISPOSED", + "RESOURCE_BUSY", + "DOWNLOAD_FAILED", + "DOWNLOAD_ABORTED", + "UNKNOWN", +] +`; diff --git a/packages/react-native-executorch/__tests__/core/error.test.ts b/packages/react-native-executorch/__tests__/core/error.test.ts new file mode 100644 index 0000000000..1fbcf6d32e --- /dev/null +++ b/packages/react-native-executorch/__tests__/core/error.test.ts @@ -0,0 +1,151 @@ +/** + * The coded error type every failure in the library carries. + * + * `RnExecuTorchError` is deliberately not a class: an error thrown inside a + * worklet crosses a JSI boundary on its way back, and prototype identity does + * not survive that trip. Apps are therefore told to branch on `code` through + * `isRnExecuTorchError`, and what has to hold is that the guard keeps working + * on an error that has been reduced to plain data — which is exactly what an + * `instanceof` check would silently get wrong. + * + * The other half is the code list itself. `VALID_ERROR_CODES` is public API and + * the native side raises the same names, so an entry appearing, disappearing or + * being renamed is an API event rather than an implementation detail. + */ +import { readFileSync, readdirSync, statSync } from 'fs'; +import { join } from 'path'; + +import { RnExecuTorchError, VALID_ERROR_CODES, isRnExecuTorchError } from '../../src/core/error'; + +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] : []; + }); +} + +/** What an error looks like after a structured-clone-style round trip. */ +const acrossABoundary = (error: Error): unknown => ({ + name: error.name, + message: error.message, + code: (error as RnExecuTorchError).code, +}); + +describe('RnExecuTorchError', () => { + it('is constructible without `new`, so a worklet can throw it', () => { + const error = RnExecuTorchError('LOAD_FAILED', 'could not read the file'); + + expect(error).toBeInstanceOf(Error); + expect(error.name).toBe('RnExecuTorchError'); + expect(error.code).toBe('LOAD_FAILED'); + expect(error.message).toBe('could not read the file'); + }); + + it('carries a stack, so a crash reporter can place it', () => { + expect(RnExecuTorchError('UNKNOWN', 'boom').stack).toEqual(expect.any(String)); + }); + + it('attaches the raw ExecuTorch runtime code only when one is given', () => { + expect(RnExecuTorchError('EXECUTION_FAILED', 'boom', 32).etRuntimeErrorCode).toBe(32); + // Absent rather than `undefined`: the field is diagnostic, and an app + // checking `'etRuntimeErrorCode' in error` should learn the truth. + expect('etRuntimeErrorCode' in RnExecuTorchError('EXECUTION_FAILED', 'boom')).toBe(false); + }); + + it('keeps a runtime code of 0 rather than dropping it as falsy', () => { + expect(RnExecuTorchError('EXECUTION_FAILED', 'boom', 0).etRuntimeErrorCode).toBe(0); + }); +}); + +describe('isRnExecuTorchError', () => { + const error = RnExecuTorchError('RESOURCE_BUSY', 'a run is already in flight'); + + it('narrows a library error', () => { + expect(isRnExecuTorchError(error)).toBe(true); + }); + + it('narrows on an exact code', () => { + expect(isRnExecuTorchError(error, 'RESOURCE_BUSY')).toBe(true); + expect(isRnExecuTorchError(error, 'RESOURCE_DISPOSED')).toBe(false); + }); + + it('holds for an error that lost its prototype crossing a boundary', () => { + // The whole reason the guard is duck-typed. `instanceof Error` is already + // false for this value. + const crossed = acrossABoundary(error); + + expect(crossed).not.toBeInstanceOf(Error); + expect(isRnExecuTorchError(crossed, 'RESOURCE_BUSY')).toBe(true); + }); + + it.each([ + ['a plain Error', new Error('boom')], + ['a TypeError', new TypeError('boom')], + ['a string', 'RESOURCE_BUSY'], + ['null', null], + ['undefined', undefined], + ['a bare object', {}], + ])('rejects %s', (_label, value) => { + expect(isRnExecuTorchError(value)).toBe(false); + }); + + it('rejects a look-alike carrying a code that is not in the list', () => { + // An object can claim the name and still be something else — a caller's own + // error type, or a code from a newer version of the library than this one. + expect(isRnExecuTorchError({ name: 'RnExecuTorchError', code: 'NOT_A_REAL_CODE' })).toBe(false); + }); + + it('rejects a look-alike with the right code but the wrong name', () => { + expect(isRnExecuTorchError({ name: 'Error', code: 'RESOURCE_BUSY' })).toBe(false); + }); + + it('rejects an error whose code is not a string', () => { + expect(isRnExecuTorchError({ name: 'RnExecuTorchError', code: 7 })).toBe(false); + }); +}); + +describe('VALID_ERROR_CODES', () => { + it('matches the recorded code list', () => { + // Public API: apps switch on these, and the native side raises the same + // names. An addition, removal or rename belongs in the pull request diff. + expect([...VALID_ERROR_CODES]).toMatchSnapshot(); + }); + + it('names every code in SCREAMING_SNAKE_CASE, without duplicates', () => { + for (const code of VALID_ERROR_CODES) expect(code).toMatch(/^[A-Z][A-Z_]*[A-Z]$/); + expect(new Set(VALID_ERROR_CODES).size).toBe(VALID_ERROR_CODES.length); + }); + + it('accepts every listed code, both to construct and to narrow', () => { + for (const code of VALID_ERROR_CODES) { + const raised = RnExecuTorchError(code, `raised as ${code}`); + expect(isRnExecuTorchError(raised, code)).toBe(true); + // And only that code — no two entries may alias each other. + const others = VALID_ERROR_CODES.filter((other) => other !== code); + expect(others.filter((other) => isRnExecuTorchError(raised, other))).toEqual([]); + } + }); + + it('covers every code `src/` actually raises', () => { + // A code raised but not listed would make `isRnExecuTorchError` reject the + // library's own error — the guard would return false for a failure the + // library itself threw. Read out of the source, since nothing types the + // argument at the throw site once a code is misspelled. + const listed = new Set(VALID_ERROR_CODES); + const raised = new Set(); + for (const file of sourceFiles()) { + const text = readFileSync(join(SRC, file), 'utf8'); + for (const [, code] of text.matchAll(/RnExecuTorchError\(\s*'([A-Z_]+)'/g)) { + raised.add(code!); + } + } + + expect(raised.size).toBeGreaterThan(0); + expect([...raised].filter((code) => !listed.has(code)).sort()).toEqual([]); + }); +}); diff --git a/packages/react-native-executorch/__tests__/support/blobUtilMock.ts b/packages/react-native-executorch/__tests__/support/blobUtilMock.ts index e74009bb8e..958a288048 100644 --- a/packages/react-native-executorch/__tests__/support/blobUtilMock.ts +++ b/packages/react-native-executorch/__tests__/support/blobUtilMock.ts @@ -25,6 +25,23 @@ const encode = (value: string | Uint8Array): Uint8Array => const decode = (data: Uint8Array): string => String.fromCharCode(...data); +const BASE64_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; + +/** Hand-rolled because neither `btoa` nor `Buffer` is in the package's `lib`. */ +/* eslint-disable no-bitwise */ +const base64Encode = (data: Uint8Array): string => { + let out = ''; + for (let i = 0; i < data.length; i += 3) { + const chunk = (data[i]! << 16) | ((data[i + 1] ?? 0) << 8) | (data[i + 2] ?? 0); + const padding = data.length - i; + out += BASE64_ALPHABET[(chunk >> 18) & 63]! + BASE64_ALPHABET[(chunk >> 12) & 63]!; + out += padding > 1 ? BASE64_ALPHABET[(chunk >> 6) & 63]! : '='; + out += padding > 2 ? BASE64_ALPHABET[chunk & 63]! : '='; + } + return out; +}; +/* eslint-enable no-bitwise */ + export const fakeFs = { /** Wipes all file and directory state. */ reset(): void { @@ -283,6 +300,14 @@ const fs = { if (!files.delete(path) && !directories.delete(path)) throw new Error(`ENOENT: ${path}`); }, + readFile: async (path: string, encoding: string): Promise => { + const data = files.get(path); + if (!data) throw new Error(`ENOENT: ${path}`); + if (encoding === 'utf8') return decode(data); + if (encoding === 'base64') return base64Encode(data); + throw new Error(`blobUtilMock: unsupported readFile encoding '${encoding}'`); + }, + mv: async (from: string, to: string): Promise => { const data = files.get(from); if (!data) throw new Error(`ENOENT: ${from}`); diff --git a/packages/react-native-executorch/__tests__/support/fakeJsi.ts b/packages/react-native-executorch/__tests__/support/fakeJsi.ts index b7fee3cd16..0c3d3f89a3 100644 --- a/packages/react-native-executorch/__tests__/support/fakeJsi.ts +++ b/packages/react-native-executorch/__tests__/support/fakeJsi.ts @@ -15,7 +15,7 @@ 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'; +import { cv, fakePhonemizer, math, speech } from './fakeOps'; // ============================================================================ // Models @@ -166,6 +166,150 @@ function createFakeTokenizer(path: string, vocabulary: FakeVocabulary) { }; } +// ============================================================================ +// LLM runners +// ============================================================================ + +/** How a fake runner answers one `generate` call. */ +export type FakeGeneration = { + /** The text the runner produces, streamed token by token (words). */ + response: string; +}; + +/** A runner `createLLMRunner` can return. */ +export type FakeRunnerProgram = { + /** Context window the runner reports. Defaults to `128`. */ + maxSeqLen?: number; + /** + * Responses handed out in order, one per `generate` call. A runner that runs + * past the end of the list repeats the last entry, so a test only has to + * script the turns it cares about. + */ + generations?: readonly FakeGeneration[]; +}; + +/** One call made on a fake runner, in order, for a test to assert against. */ +export type RunnerCall = + | { kind: 'prefill'; text: string; pos: number } + | { kind: 'generate'; text: string; pos: number } + | { kind: 'reset'; targetPos: number } + | { kind: 'stop' }; + +const runnerPrograms = new Map(); +const liveRunners = new Set(); +const runnerCalls: RunnerCall[] = []; + +/** The text of a prompt, ignoring any media inputs a multimodal prompt carries. */ +const promptText = (prompt: unknown): string => + typeof prompt === 'string' + ? prompt + : Array.isArray(prompt) + ? prompt.filter((part) => typeof part === 'string').join('') + : ''; + +/** + * Builds a fake LLM runner. + * + * Its KV cache is modelled as a token count that prefill and generate both + * advance and `reset` rewinds, which is the only part of the native runner's + * state the chat session actually reasons about: it diffs the position across a + * turn to decide what still has to be prefilled, and rewinds to a recorded + * position between tool turns. + * @param modelPath The model path. + * @param tokenizerPath The tokenizer path. + * @param modalities The non-text modalities the runner accepts. + * @param program What the runner generates. + * @returns The fake runner. + */ +function createFakeRunner( + modelPath: string, + tokenizerPath: string, + modalities: readonly string[], + program: FakeRunnerProgram +) { + const maxSeqLen = program.maxSeqLen ?? 128; + let disposed = false; + let pos = 0; + let generationIndex = 0; + let stopped = false; + liveRunners.add(modelPath); + + const assertLive = (op: string): void => { + if (disposed) throw new Error(`${op}: runner '${modelPath}' has been disposed`); + }; + + // One token per whitespace-separated word, matching the fake tokenizer. + const countTokens = (text: string): number => text.split(/\s+/).filter(Boolean).length; + + return { + modelPath, + tokenizerPath, + modalities, + + prefill: (prompt: unknown): void => { + assertLive('prefill'); + const text = promptText(prompt); + pos = Math.min(maxSeqLen, pos + countTokens(text)); + runnerCalls.push({ kind: 'prefill', text, pos }); + }, + + generate: (prompt: unknown, _config?: unknown, onToken?: (token: string) => void) => { + assertLive('generate'); + stopped = false; + const text = promptText(prompt); + pos = Math.min(maxSeqLen, pos + countTokens(text)); + + const scripted = program.generations ?? []; + const generation = scripted[Math.min(generationIndex, scripted.length - 1)]; + generationIndex++; + const response = generation?.response ?? ''; + + let emitted = 0; + for (const token of response.split(/(?<=\s)/)) { + if (stopped) break; + onToken?.(token); + emitted++; + pos = Math.min(maxSeqLen, pos + 1); + } + runnerCalls.push({ kind: 'generate', text, pos }); + + return { + numPromptTokens: countTokens(text), + numGeneratedTokens: emitted, + firstTokenMs: 0, + inferenceStartMs: 0, + inferenceEndMs: 0, + modelLoadStartMs: 0, + modelLoadEndMs: 0, + }; + }, + + reset: (targetPos?: number): void => { + assertLive('reset'); + pos = targetPos ?? 0; + runnerCalls.push({ kind: 'reset', targetPos: pos }); + }, + + stop: (): void => { + stopped = true; + runnerCalls.push({ kind: 'stop' }); + }, + + getKVCacheState: () => ({ + pos, + maxSeqLen, + remainingTokens: maxSeqLen - pos, + usageRatio: pos / maxSeqLen, + }), + + dispose: (): void => { + if (disposed) return; + disposed = true; + liveRunners.delete(modelPath); + }, + }; +} + // ============================================================================ // The global // ============================================================================ @@ -190,6 +334,15 @@ const jsi = { math, cv, speech, + llm: { + createLLMRunner: (modelPath: string, tokenizerPath: string, modalities: string[]) => { + const program = runnerPrograms.get(modelPath); + if (!program) { + throw new Error(`createLLMRunner: no runner registered at '${modelPath}'`); + } + return createFakeRunner(modelPath, tokenizerPath, modalities, program); + }, + }, nlp: { loadTokenizer: (path: string) => { const vocabulary = vocabularies.get(path); @@ -232,6 +385,30 @@ export const fakeJsi = { vocabularies.set(path, vocabulary); }, + /** + * Makes `createLLMRunner(path, ...)` succeed and return a scripted runner. + * @param path The model path a chat session will be pointed at. + * @param program The context window and the responses to generate. + */ + registerLLMRunner(path: string, program: FakeRunnerProgram = {}): void { + runnerPrograms.set(path, program); + }, + + /** @returns Every call made on a fake LLM runner so far, in order. */ + runnerCalls(): readonly RunnerCall[] { + return runnerCalls; + }, + + /** @returns Model paths of LLM runners that were created and not disposed. */ + liveRunners(): string[] { + return [...liveRunners].sort(); + }, + + /** @returns Languages of phonemizers that were created and not disposed. */ + livePhonemizers(): string[] { + return fakePhonemizer.live(); + }, + /** * Overrides what `getRegisteredBackends()` reports. * @param backends The backend names to report. @@ -277,11 +454,15 @@ export const fakeJsi = { reset(): void { programs.clear(); vocabularies.clear(); + runnerPrograms.clear(); liveModels.clear(); liveTokenizers.clear(); + liveRunners.clear(); executions.length = 0; + runnerCalls.length = 0; registeredBackends = ['XnnpackBackend', 'CoreMLBackend']; jsi.isEmulator = false; + fakePhonemizer.reset(); tensorTracker.reset(); }, }; diff --git a/packages/react-native-executorch/__tests__/support/fakeOps.ts b/packages/react-native-executorch/__tests__/support/fakeOps.ts index 3ef89a1c0c..95cd7ef521 100644 --- a/packages/react-native-executorch/__tests__/support/fakeOps.ts +++ b/packages/react-native-executorch/__tests__/support/fakeOps.ts @@ -107,6 +107,31 @@ export const math = { return dst; }, + /** + * Reads one value per lane out of `src` at the position `indices` names, + * mirroring `argmax`'s output shape so the two compose. + */ + gather(src: FakeTensor, indices: 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(indices, expected, 'gather: indices'); + expectShape(dst, expected, 'gather: dst'); + const { outer, length, inner } = axisLayout(src.shape, resolved); + + for (let o = 0; o < outer; o++) { + for (let i = 0; i < inner; i++) { + const index = indices.getElement(o * inner + i); + if (index < 0 || index >= length) { + throw new Error( + `gather: index ${index} is outside the gathered axis of length ${length}` + ); + } + dst.setElement(o * inner + i, src.getElement((o * length + index) * inner + i)); + } + } + return dst; + }, + threshold(src: FakeTensor, dst: FakeTensor, thresholdVal: number): FakeTensor { expectShape(dst, src.shape, 'threshold: dst'); for (let i = 0; i < src.numel; i++) { @@ -371,6 +396,139 @@ export const cv = { return opts.nmsType === 'weighted' ? groups : kept; }, + /** + * Decodes a DBNet probability map the way the native op documents it: + * binarize at `binThreshold`, trace each connected region, score it by the + * mean probability inside its bounds, and unclip the survivors back out to + * their unshrunk size. + * + * The real op traces contours and unclips a polygon; this takes the + * axis-aligned bounds of each 4-connected component and grows them by the + * same ratio, which produces the same quads for the rectangular regions a + * pipeline test draws and keeps every documented threshold load-bearing. + */ + extractDbnetTextQuads( + probabilityMap: FakeTensor, + options: { + binThreshold: number; + boxThreshold: number; + unclipRatio: number; + minBoxSide: number; + maxCandidates: number; + } + ): Float32Array { + const [, , height, width] = probabilityMap.shape as [number, number, number, number]; + const at = (y: number, x: number) => probabilityMap.getElement(y * width + x); + + const seen = new Uint8Array(height * width); + const quads: number[] = []; + + for (let y0 = 0; y0 < height; y0++) { + for (let x0 = 0; x0 < width; x0++) { + if (seen[y0 * width + x0] || at(y0, x0) < options.binThreshold) continue; + if (quads.length / 8 >= options.maxCandidates) break; + + // Flood the component, tracking its bounds and its probability mass. + let minX = x0; + let maxX = x0; + let minY = y0; + let maxY = y0; + let sum = 0; + let count = 0; + const stack = [[y0, x0] as const]; + seen[y0 * width + x0] = 1; + while (stack.length > 0) { + const [y, x] = stack.pop()!; + minX = Math.min(minX, x); + maxX = Math.max(maxX, x); + minY = Math.min(minY, y); + maxY = Math.max(maxY, y); + sum += at(y, x); + count++; + for (const [dy, dx] of [ + [-1, 0], + [1, 0], + [0, -1], + [0, 1], + ] as const) { + const [ny, nx] = [y + dy, x + dx]; + if (ny < 0 || nx < 0 || ny >= height || nx >= width) continue; + if (seen[ny * width + nx] || at(ny, nx) < options.binThreshold) continue; + seen[ny * width + nx] = 1; + stack.push([ny, nx]); + } + } + + if (sum / count < options.boxThreshold) continue; + if (maxX - minX + 1 < options.minBoxSide || maxY - minY + 1 < options.minBoxSide) continue; + + // Unclip: grow the shrunk box back out, clamped to the map. + const growX = ((maxX - minX + 1) * (options.unclipRatio - 1)) / 2; + const growY = ((maxY - minY + 1) * (options.unclipRatio - 1)) / 2; + const left = Math.max(0, minX - growX); + const right = Math.min(width - 1, maxX + growX); + const top = Math.max(0, minY - growY); + const bottom = Math.min(height - 1, maxY + growY); + + quads.push(left, top, right, top, right, bottom, left, bottom); + } + } + + return Float32Array.from(quads); + }, + + /** + * Rectifies a quad region of `src` into the pre-allocated canvas `dst`. + * + * The native op does a perspective warp; this samples the quad's axis-aligned + * bounds with nearest-neighbor into the canvas's content area and pads the + * rest, which is the same result for the axis-aligned regions a pipeline test + * draws — and it keeps `contentWidth`, `align` and `padValue` real, since + * those are what the caller has to get right. + */ + rectifyQuad( + src: FakeTensor, + dst: FakeTensor, + quad: ArrayLike, + options: { contentWidth: number; align: 'left' | 'center'; padValue: number } + ): 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(`rectifyQuad: channel mismatch, src ${channels} vs dst ${dstChannels}`); + } + + const xs = [quad[0]!, quad[2]!, quad[4]!, quad[6]!]; + const ys = [quad[1]!, quad[3]!, quad[5]!, quad[7]!]; + const [left, right] = [Math.min(...xs), Math.max(...xs)]; + const [top, bottom] = [Math.min(...ys), Math.max(...ys)]; + + for (let i = 0; i < dst.numel; i++) dst.setElement(i, options.padValue); + + const content = Math.max(0, Math.min(options.contentWidth, dstW)); + const offset = options.align === 'center' ? Math.floor((dstW - content) / 2) : 0; + + for (let y = 0; y < dstH; y++) { + for (let x = 0; x < content; x++) { + const sx = Math.min( + srcW - 1, + Math.round(left + ((right - left) * x) / Math.max(1, content - 1)) + ); + const sy = Math.min( + srcH - 1, + Math.round(top + ((bottom - top) * y) / Math.max(1, dstH - 1)) + ); + for (let c = 0; c < channels; c++) { + dst.setElement( + (y * dstW + offset + x) * channels + c, + src.getElement((sy * srcW + sx) * channels + c) + ); + } + } + } + return dst; + }, + restrictToBox( src: FakeTensor, dst: FakeTensor, @@ -404,7 +562,54 @@ export const cv = { // speech // ============================================================================ +/** What a fake phonemizer returns for a given input. */ +const phonemizations = new Map(); +const livePhonemizers = new Set(); + +export const fakePhonemizer = { + /** + * Makes the fake phonemizer answer `text` with `phonemes`. Anything not + * registered is phonemized by lowercasing, which is enough for a pipeline + * test: what matters downstream is the phoneme count, not the alphabet. + * @param text The input to script. + * @param phonemes What to return for it. + */ + serve(text: string, phonemes: string): void { + phonemizations.set(text, phonemes); + }, + /** @returns Languages of phonemizers that were created and not disposed. */ + live(): string[] { + return [...livePhonemizers].sort(); + }, + /** Clears every scripted phonemization. Runs automatically between tests. */ + reset(): void { + phonemizations.clear(); + livePhonemizers.clear(); + }, +}; + export const speech = { + /** + * Builds a grapheme-to-phoneme converter. It is a native host object rather + * than a model, so it is registered and disposed independently of the two + * `.pte` files the Kokoro pipeline loads. + */ + createPhonemizer(config: { lang: string }) { + let disposed = false; + livePhonemizers.add(config.lang); + return { + phonemize: (text: string): string => { + if (disposed) throw new Error(`phonemize: phonemizer '${config.lang}' has been disposed`); + return phonemizations.get(text) ?? text.toLowerCase(); + }, + dispose: (): void => { + if (disposed) return; + disposed = true; + livePhonemizers.delete(config.lang); + }, + }; + }, + /** * Frames a waveform exactly as documented on `extractFrames`: per-frame mean * removal, pre-emphasis, Hann windowing, into zero-padded rows of `dst`. diff --git a/packages/react-native-executorch/__tests__/support/setup.ts b/packages/react-native-executorch/__tests__/support/setup.ts index b55026d923..7782be5208 100644 --- a/packages/react-native-executorch/__tests__/support/setup.ts +++ b/packages/react-native-executorch/__tests__/support/setup.ts @@ -47,7 +47,17 @@ afterEach(async () => { const tensors = fakeJsi.liveTensorDescriptions(); const models = fakeJsi.liveModels(); const tokenizers = fakeJsi.liveTokenizers(); - if (tensors.length === 0 && models.length === 0 && tokenizers.length === 0) return; + const runners = fakeJsi.liveRunners(); + const phonemizers = fakeJsi.livePhonemizers(); + if ( + tensors.length === 0 && + models.length === 0 && + tokenizers.length === 0 && + runners.length === 0 && + phonemizers.length === 0 + ) { + return; + } throw new Error( [ @@ -55,6 +65,8 @@ afterEach(async () => { tensors.length > 0 ? ` tensors: ${tensors.join(', ')}` : '', models.length > 0 ? ` models: ${models.join(', ')}` : '', tokenizers.length > 0 ? ` tokenizers: ${tokenizers.join(', ')}` : '', + runners.length > 0 ? ` LLM runners: ${runners.join(', ')}` : '', + phonemizers.length > 0 ? ` phonemizers: ${phonemizers.join(', ')}` : '', 'Dispose the pipeline, or call allowNativeLeaks() if the leak is the point of the test.', ] .filter(Boolean) diff --git a/packages/react-native-executorch/__tests__/tasks/kokoroTextToSpeech.test.ts b/packages/react-native-executorch/__tests__/tasks/kokoroTextToSpeech.test.ts new file mode 100644 index 0000000000..635b2b5e62 --- /dev/null +++ b/packages/react-native-executorch/__tests__/tasks/kokoroTextToSpeech.test.ts @@ -0,0 +1,399 @@ +/** + * The Kokoro text-to-speech pipeline. + * + * Two `.pte` files, a native phonemizer and a set of voice matrices read off + * disk, wired together by a long stateful worklet. The waveform itself depends + * on real weights, so what this pins down is the contract around it: the exact + * signatures the two sub-models must export and how a mismatch is reported, the + * argument validation every caller hits, the chunking that keeps a long input + * inside the models' token window, the streaming generator's shape, and the + * disposal of five separate native resources — two models, the phonemizer and + * the tensors the pipeline keeps alive for its whole life. + */ +import { RangeDim, bool, constraint, f32, i64, method } from '../../src/core/schema'; +import { + KOKORO_SAMPLE_RATE, + createKokoroTextToSpeech, +} from '../../src/extensions/speech/tasks/kokoroTextToSpeech'; +import { fakeJsi } from '../support/fakeJsi'; +import { fakePhonemizer } from '../support/fakeOps'; +import { fakeFs } from '../support/blobUtilMock'; +import { exported } from '../support/fixtures'; +import { tracked } from '../support/lifetime'; +import { allowNativeLeaks } from '../support/setup'; + +const PREDICTOR_PATH = '/models/duration_predictor.pte'; +const SYNTHESIZER_PATH = '/models/synthesizer.pte'; +const VOICE_PATH = '/models/voices/af_heart.bin'; + +// Fixed by the exported models rather than by the pipeline. +const VOICE_REF_SIZE = 256; +const DURATION_FEATURE_DIM = 640; +const TICKS_PER_DURATION = 600; + +const MAX_TOKENS = 32; +const MAX_DURATION_TICKS = 512; + +const predictorSchema = (maxTokens = MAX_TOKENS) => + exported( + method( + 'forward', + [ + i64(1, RangeDim(2, maxTokens)), + bool(1, RangeDim(2, maxTokens)), + f32(1, VOICE_REF_SIZE / 2), + f32(1), + ], + [i64(RangeDim(2, maxTokens)), f32(1, RangeDim(2, maxTokens), DURATION_FEATURE_DIM)], + [ + constraint.equality( + { paramSide: 'input', tensorIdx: 0, dimIdx: 1 }, + { paramSide: 'input', tensorIdx: 1, dimIdx: 1 }, + { paramSide: 'output', tensorIdx: 0, dimIdx: 0 }, + { paramSide: 'output', tensorIdx: 1, dimIdx: 1 } + ), + ] + ) + ); + +const synthesizerSchema = (maxTokens = MAX_TOKENS) => + exported( + method( + 'forward', + [ + i64(1, RangeDim(2, maxTokens)), + bool(1, RangeDim(2, maxTokens)), + i64(RangeDim(1, MAX_DURATION_TICKS)), + f32(1, RangeDim(2, maxTokens), DURATION_FEATURE_DIM), + f32(1, VOICE_REF_SIZE), + ], + [f32(1, 1, RangeDim(TICKS_PER_DURATION, MAX_DURATION_TICKS * TICKS_PER_DURATION))], + [ + constraint.equality( + { paramSide: 'input', tensorIdx: 0, dimIdx: 1 }, + { paramSide: 'input', tensorIdx: 1, dimIdx: 1 }, + { paramSide: 'input', tensorIdx: 3, dimIdx: 1 } + ), + constraint.linear( + { paramSide: 'output', tensorIdx: 0, dimIdx: 2 }, + { paramSide: 'input', tensorIdx: 2, dimIdx: 0 }, + TICKS_PER_DURATION + ), + ] + ) + ); + +/** + * A duration predictor that gives every token the same number of ticks, so the + * waveform length a test sees follows directly from the token count. + * @param ticksPerToken Ticks predicted for each token. + * @returns The execute implementation. + */ +const predictsDurations = + (ticksPerToken: number) => + ( + _methodName: string, + _inputs: readonly unknown[], + outputs: readonly { numel: number; setElement: (i: number, v: number) => void }[] + ) => { + const durations = outputs[0]!; + for (let i = 0; i < durations.numel; i++) durations.setElement(i, ticksPerToken); + }; + +/** A synthesizer that emits a constant tone, so trimming is observable. */ +const emitsTone = ( + _methodName: string, + _inputs: readonly unknown[], + outputs: readonly { numel: number; setElement: (i: number, v: number) => void }[] +) => { + const audio = outputs[0]!; + for (let i = 0; i < audio.numel; i++) audio.setElement(i, 0.5); +}; + +/** Writes a voice matrix of `rows` reference vectors to the fake filesystem. */ +const writeVoice = (rows = MAX_TOKENS) => { + const matrix = new Float32Array(rows * VOICE_REF_SIZE); + for (let i = 0; i < matrix.length; i++) matrix[i] = (i % 100) / 100; + fakeFs.write(VOICE_PATH, new Uint8Array(matrix.buffer)); +}; + +// `af_heart` is a published Kokoro voice name, not an identifier this suite chose. +/* eslint-disable camelcase */ +const config = { + name: 'kokoro', + modelPaths: { durationPredictor: PREDICTOR_PATH, synthesizer: SYNTHESIZER_PATH }, + phonemizer: { lang: 'en-us' }, + voices: { af_heart: VOICE_PATH }, +} as const; +/* eslint-enable camelcase */ + +const registerModels = (predictor = predictorSchema(), synthesizer = synthesizerSchema()): void => { + fakeJsi.registerModel(PREDICTOR_PATH, { + schema: predictor, + execute: predictsDurations(8), + }); + fakeJsi.registerModel(SYNTHESIZER_PATH, { schema: synthesizer, execute: emitsTone }); +}; + +/** Drains a synthesis generator into an array. */ +const collect = async (stream: AsyncGenerator): Promise => { + const chunks: T[] = []; + for await (const chunk of stream) chunks.push(chunk); + return chunks; +}; + +beforeEach(() => { + writeVoice(); + registerModels(); +}); + +describe('createKokoroTextToSpeech — the model contract', () => { + it('accepts the exported pair and exposes the synthesis API', async () => { + const tts = tracked(await createKokoroTextToSpeech(config)); + + expect(tts.synthesize).toBeInstanceOf(Function); + expect(tts.synthesizeStop).toBeInstanceOf(Function); + }); + + it('rejects a duration predictor whose feature width is wrong', async () => { + fakeJsi.registerModel(PREDICTOR_PATH, { + schema: exported( + method( + 'forward', + [ + i64(1, RangeDim(2, MAX_TOKENS)), + bool(1, RangeDim(2, MAX_TOKENS)), + f32(1, VOICE_REF_SIZE / 2), + f32(1), + ], + [i64(RangeDim(2, MAX_TOKENS)), f32(1, RangeDim(2, MAX_TOKENS), DURATION_FEATURE_DIM + 1)] + ) + ), + }); + + await expect(createKokoroTextToSpeech(config)).rejects.toThrow(/Constant dimension mismatch/); + }); + + it('rejects a synthesizer that does not tie its audio length to the durations', async () => { + // Without the linear constraint the pipeline cannot size the output tensor, + // so a model missing it has to be refused at construction rather than at + // the first synthesis. + fakeJsi.registerModel(SYNTHESIZER_PATH, { + schema: exported( + method( + 'forward', + [ + i64(1, RangeDim(2, MAX_TOKENS)), + bool(1, RangeDim(2, MAX_TOKENS)), + i64(RangeDim(1, MAX_DURATION_TICKS)), + f32(1, RangeDim(2, MAX_TOKENS), DURATION_FEATURE_DIM), + f32(1, VOICE_REF_SIZE), + ], + [f32(1, 1, RangeDim(1, MAX_DURATION_TICKS * TICKS_PER_DURATION))] + ) + ), + }); + + await expect(createKokoroTextToSpeech(config)).rejects.toThrow(/constraint/i); + }); + + it('releases both models, the phonemizer and every tensor when construction fails', async () => { + // The pipeline loads before it validates, so a rejected schema must not + // leave the loaded half behind. The suite's global leak check would catch + // it, but this states it as the point of the test. + fakeJsi.registerModel(SYNTHESIZER_PATH, { + schema: exported(method('forward', [f32(1)], [f32(1)])), + }); + + await expect(createKokoroTextToSpeech(config)).rejects.toThrow(); + + expect(fakeJsi.liveModels()).toEqual([]); + expect(fakeJsi.livePhonemizers()).toEqual([]); + expect(fakeJsi.liveTensors()).toBe(0); + }); + + it('surfaces a missing voice file rather than resolving a broken pipeline', async () => { + fakeFs.remove(VOICE_PATH); + + await expect(createKokoroTextToSpeech(config)).rejects.toThrow(/ENOENT/); + allowNativeLeaks(); // see `tasks/constructionFailure.test.ts` + }); + + it('releases both models, the phonemizer and its tensors on dispose', async () => { + const tts = await createKokoroTextToSpeech(config); + + tts.dispose(); + + expect(fakeJsi.liveModels()).toEqual([]); + expect(fakeJsi.livePhonemizers()).toEqual([]); + expect(fakeJsi.liveTensors()).toBe(0); + }); +}); + +describe('createKokoroTextToSpeech — argument validation', () => { + it.each([ + ['an empty string', ''], + ['whitespace only', ' '], + ])('rejects %s', async (_label, text) => { + const tts = tracked(await createKokoroTextToSpeech(config)); + + await expect(collect(tts.synthesize(text, { voice: 'af_heart' }))).rejects.toThrow( + /cannot be empty/ + ); + }); + + it('rejects a voice the config does not define', async () => { + const tts = tracked(await createKokoroTextToSpeech(config)); + + await expect( + // @ts-expect-error the voice keys are inferred from the config + collect(tts.synthesize('hello', { voice: 'not_a_voice' })) + ).rejects.toThrow(/Unknown voice/); + }); + + it.each([ + ['too slow', 0.05], + ['too fast', 4], + ])('rejects a speed that is %s', async (_label, speed) => { + const tts = tracked(await createKokoroTextToSpeech(config)); + + await expect(collect(tts.synthesize('hello', { voice: 'af_heart', speed }))).rejects.toThrow( + /speed must be between/ + ); + }); + + it('refuses a second synthesis while one is still running', async () => { + const tts = tracked(await createKokoroTextToSpeech(config)); + + const first = tts.synthesize('hello there my friend', { voice: 'af_heart' }); + await first.next(); + + await expect(collect(tts.synthesize('again', { voice: 'af_heart' }))).rejects.toThrow( + /already in progress/ + ); + + await collect(first); // let the first stream finish, releasing the lock + }); + + it('runs another synthesis once the first has finished', async () => { + const tts = tracked(await createKokoroTextToSpeech(config)); + await collect(tts.synthesize('hello', { voice: 'af_heart' })); + + await expect(collect(tts.synthesize('again', { voice: 'af_heart' }))).resolves.not.toEqual([]); + }); +}); + +describe('createKokoroTextToSpeech — synthesis', () => { + it('streams a chunk carrying audio at the Kokoro sample rate', async () => { + const tts = tracked(await createKokoroTextToSpeech(config)); + + const [chunk, ...rest] = await collect(tts.synthesize('hello', { voice: 'af_heart' })); + + expect(rest).toEqual([]); + expect(chunk).toMatchObject({ + audio: expect.any(Float32Array), + sampleRate: KOKORO_SAMPLE_RATE, + chunkIndex: 0, + totalChunks: 1, + }); + expect(chunk!.duration).toBeCloseTo(chunk!.audio.length / KOKORO_SAMPLE_RATE); + }); + + it('phonemizes the input before tokenizing it', async () => { + fakePhonemizer.serve('Hello', 'həlˈoʊ'); + const tts = tracked(await createKokoroTextToSpeech(config)); + + await collect(tts.synthesize('Hello', { voice: 'af_heart' })); + + // Both sub-models ran, which they only do once phonemes reached the tokenizer. + expect(fakeJsi.executions().map(({ path }) => path)).toEqual([ + PREDICTOR_PATH, + SYNTHESIZER_PATH, + ]); + }); + + it('takes the text as phonemes directly when asked not to phonemize', async () => { + fakePhonemizer.serve('həlˈoʊ', 'THE PHONEMIZER RAN'); + const tts = tracked(await createKokoroTextToSpeech(config)); + + const chunks = await collect(tts.synthesize('həlˈoʊ', { voice: 'af_heart', phonemize: false })); + + expect(chunks).toHaveLength(1); + }); + + it('splits an input too long for the models into several chunks', async () => { + const tts = tracked(await createKokoroTextToSpeech(config)); + + // The window is 32 tokens, two of which are padding, so a longer phoneme + // run cannot be synthesized in one pass. + const chunks = await collect( + tts.synthesize('one two three four five six seven eight nine ten eleven twelve', { + voice: 'af_heart', + }) + ); + + expect(chunks.length).toBeGreaterThan(1); + expect(chunks.map((chunk) => chunk.chunkIndex)).toEqual(chunks.map((_chunk, index) => index)); + expect(new Set(chunks.map((chunk) => chunk.totalChunks))).toEqual(new Set([chunks.length])); + }); + + it('honors a maxChunkLength smaller than the model window', async () => { + const tts = tracked(await createKokoroTextToSpeech(config)); + const text = 'one two three four five six seven eight'; + + const wide = await collect(tts.synthesize(text, { voice: 'af_heart' })); + const narrow = await collect(tts.synthesize(text, { voice: 'af_heart', maxChunkLength: 10 })); + + expect(narrow.length).toBeGreaterThan(wide.length); + }); + + it('rejects a maxChunkLength the partitioner cannot honor', async () => { + const tts = tracked(await createKokoroTextToSpeech(config)); + + await expect( + collect(tts.synthesize('hello', { voice: 'af_heart', maxChunkLength: 4 })) + ).rejects.toThrow(/below minimum/); + }); + + it('stops streaming when synthesizeStop is called mid-stream', async () => { + const tts = tracked(await createKokoroTextToSpeech(config)); + + const stream = tts.synthesize( + 'one two three four five six seven eight nine ten eleven twelve', + { voice: 'af_heart' } + ); + const first = await stream.next(); + tts.synthesizeStop(); + const after = await stream.next(); + + expect(first.done).toBe(false); + expect(after.done).toBe(true); + expect(first.value!.totalChunks).toBeGreaterThan(1); + }); + + it('leaves no per-call tensor behind once a stream is drained', async () => { + const tts = tracked(await createKokoroTextToSpeech(config)); + const before = fakeJsi.liveTensors(); + + await collect( + tts.synthesize('one two three four five six seven eight nine ten', { voice: 'af_heart' }) + ); + + // Every window allocates and frees its own tensors; only the three the + // pipeline holds for its lifetime survive. + expect(fakeJsi.liveTensors()).toBe(before); + }); + + it('leaves no tensor behind when a stream is abandoned part-way', async () => { + const tts = tracked(await createKokoroTextToSpeech(config)); + const before = fakeJsi.liveTensors(); + + const stream = tts.synthesize('one two three four five six seven eight nine ten', { + voice: 'af_heart', + }); + await stream.next(); + await stream.return(undefined as never); + + expect(fakeJsi.liveTensors()).toBe(before); + }); +}); diff --git a/packages/react-native-executorch/__tests__/tasks/llmChatSession.test.ts b/packages/react-native-executorch/__tests__/tasks/llmChatSession.test.ts new file mode 100644 index 0000000000..919ace54af --- /dev/null +++ b/packages/react-native-executorch/__tests__/tasks/llmChatSession.test.ts @@ -0,0 +1,410 @@ +/** + * The LLM chat session. + * + * The generation itself belongs to the native runner, so what this suite owns + * is everything the session does around it: the history it keeps, the prompt it + * renders through the model's own chat template, the KV cache bookkeeping that + * lets a turn prefill only what is new, the tool-calling loop, and the rollback + * that has to leave the session usable after a failed turn. + * + * The fake runner models its KV cache as a token position that prefill and + * generate advance and `reset` rewinds — which is the only part of the native + * state the session reasons about — and hands out scripted responses, so a test + * can drive a tool loop without any weights. + */ +import { createLLMChatSession } from '../../src/extensions/llm/tasks/llmChatSession'; +import type { ToolCall, ToolParserResult } from '../../src/extensions/llm/utils/toolCalling'; +import { fakeJsi } from '../support/fakeJsi'; +import { fakeFs } from '../support/blobUtilMock'; +import { tracked } from '../support/lifetime'; +import { allowNativeLeaks } from '../support/setup'; + +const MODEL_PATH = '/models/llm.pte'; +const TOKENIZER_PATH = '/models/tokenizer.json'; +const TOKENIZER_CONFIG_PATH = '/models/tokenizer_config.json'; +const EOS = '<|eot|>'; + +// A minimal but real Jinja chat template: the session renders the prompt with +// `@huggingface/jinja`, so a hand-written string here exercises the same path a +// published model's `chat_template` takes. +const CHAT_TEMPLATE = [ + '{% for message in messages %}', + '<|{{ message.role }}|>{{ message.content }}<|end|>', + '{% endfor %}', + '{% if add_generation_prompt %}<|assistant|>{% endif %}', +].join(''); + +const config = { + modelPath: MODEL_PATH, + tokenizerPath: TOKENIZER_PATH, + tokenizerConfigPath: TOKENIZER_CONFIG_PATH, +}; + +// The keys are snake_case because they are the published `tokenizer_config.json` +// field names, which the session reads verbatim. +/* eslint-disable camelcase */ +const writeTokenizerConfig = (extra: Record = {}) => + fakeFs.write( + TOKENIZER_CONFIG_PATH, + JSON.stringify({ chat_template: CHAT_TEMPLATE, eos_token: EOS, ...extra }) + ); + +const writeRawTokenizerConfig = (raw: Record) => + fakeFs.write(TOKENIZER_CONFIG_PATH, JSON.stringify(raw)); + +const CONFIG_WITHOUT_TEMPLATE = { eos_token: EOS }; +const CONFIG_WITHOUT_EOS = { chat_template: CHAT_TEMPLATE }; +const NAMED_TEMPLATES = { + chat_template: [ + { name: 'tool_use', template: '{{ "wrong" }}' }, + { name: 'default', template: CHAT_TEMPLATE }, + ], +}; +/* eslint-enable camelcase */ + +/** Everything the session sent to the runner this test, prefill and generate. */ +const promptsSent = (): string[] => + fakeJsi + .runnerCalls() + .filter((call) => call.kind === 'prefill' || call.kind === 'generate') + .map((call) => (call as { text: string }).text); + +beforeEach(() => { + writeTokenizerConfig(); + fakeJsi.registerLLMRunner(MODEL_PATH, { generations: [{ response: 'hello there ' }] }); +}); + +describe('createLLMChatSession — construction', () => { + it('rejects a tokenizer config without a chat template', async () => { + writeRawTokenizerConfig(CONFIG_WITHOUT_TEMPLATE); + + await expect(createLLMChatSession(config)).rejects.toThrow(/chat_template/); + }); + + it('rejects a tokenizer config without an eos token', async () => { + writeRawTokenizerConfig(CONFIG_WITHOUT_EOS); + + await expect(createLLMChatSession(config)).rejects.toThrow(/eos_token/); + }); + + it('picks the default entry when the config ships several named templates', async () => { + writeTokenizerConfig(NAMED_TEMPLATES); + const session = tracked(await createLLMChatSession(config)); + + await session.sendMessage('hi'); + + expect(promptsSent().join('')).toContain('<|user|>hi<|end|>'); + }); + + it('starts with an empty history and an empty KV cache', async () => { + const session = tracked(await createLLMChatSession(config)); + + expect(session.getHistory()).toEqual([]); + expect(session.getKVCacheState().pos).toBe(0); + }); + + it('prefills the initial messages without asking for a generation', async () => { + const session = tracked( + await createLLMChatSession(config, { + initialMessages: [{ role: 'system', content: 'be brief' }], + }) + ); + + expect(session.getHistory()).toEqual([{ role: 'system', content: 'be brief' }]); + expect(fakeJsi.runnerCalls().map((call) => call.kind)).toEqual(['prefill']); + expect(session.getKVCacheState().pos).toBeGreaterThan(0); + }); + + it('releases the runner on dispose', async () => { + const session = await createLLMChatSession(config); + + session.dispose(); + + expect(fakeJsi.liveRunners()).toEqual([]); + }); +}); + +describe('createLLMChatSession — a turn', () => { + it('appends the user message and the assistant reply to the history', async () => { + const session = tracked(await createLLMChatSession(config)); + + const result = await session.sendMessage('hi'); + + expect(session.getHistory()).toEqual([ + { role: 'user', content: 'hi' }, + { role: 'assistant', content: 'hello there ' }, + ]); + expect(result.messages).toEqual(session.getHistory()); + expect(result.finishReason).toBe('stop'); + }); + + it('renders the prompt through the model chat template', async () => { + const session = tracked(await createLLMChatSession(config)); + + await session.sendMessage('hi'); + + const sent = promptsSent().join(''); + expect(sent).toContain('<|user|>hi<|end|>'); + // The generation prompt is appended only for the generate call, never for + // the prefill that commits the user message. + expect(sent).toContain('<|assistant|>'); + }); + + it('streams every token to the callback', async () => { + fakeJsi.registerLLMRunner(MODEL_PATH, { generations: [{ response: 'one two three' }] }); + const session = tracked(await createLLMChatSession(config)); + const tokens: string[] = []; + + await session.sendMessage('hi', (token) => tokens.push(token)); + // `scheduleOnRN` defers the callback by a macrotask, as the real dispatch does. + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(tokens.join('')).toBe('one two three'); + }); + + it('keeps the eos token out of the response and out of the stream', async () => { + fakeJsi.registerLLMRunner(MODEL_PATH, { generations: [{ response: `done ${EOS}` }] }); + const session = tracked(await createLLMChatSession(config)); + const tokens: string[] = []; + + const result = await session.sendMessage('hi', (token) => tokens.push(token)); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(result.messages.at(-1)!.content).toBe('done '); + expect(tokens).not.toContain(EOS); + }); + + it('stops generating as soon as the stop pattern matches', async () => { + fakeJsi.registerLLMRunner(MODEL_PATH, { generations: [{ response: 'keep going STOP more' }] }); + const session = tracked(await createLLMChatSession(config, { stopRegex: /STOP/ })); + + const result = await session.sendMessage('hi'); + + expect(result.messages.at(-1)!.content).toContain('STOP'); + expect(result.messages.at(-1)!.content).not.toContain('more'); + }); + + it('reports the generation statistics of the turn', async () => { + const session = tracked(await createLLMChatSession(config)); + + const [stats, ...rest] = (await session.sendMessage('hi')).stats; + + expect(rest).toEqual([]); + expect(stats).toMatchObject({ + numGeneratedTokens: expect.any(Number), + numPromptTokens: expect.any(Number), + prefillDurationMs: expect.any(Number), + }); + }); + + it('only prefills what is new on the second turn', async () => { + const session = tracked(await createLLMChatSession(config)); + await session.sendMessage('first question'); + + const before = fakeJsi.runnerCalls().length; + await session.sendMessage('second question'); + + const secondTurn = fakeJsi + .runnerCalls() + .slice(before) + .filter((call) => call.kind === 'prefill') + .map((call) => (call as { text: string }).text); + + // The first turn is already in the KV cache, so it must not be re-sent. + expect(secondTurn.join('')).toContain('second question'); + expect(secondTurn.join('')).not.toContain('first question'); + }); + + it('re-prefills the whole conversation each turn when asked to reset', async () => { + const session = tracked(await createLLMChatSession(config, { resetOnTurn: true })); + await session.sendMessage('first question'); + + const before = fakeJsi.runnerCalls().length; + await session.sendMessage('second question'); + + const secondTurn = fakeJsi.runnerCalls().slice(before); + expect(secondTurn[0]).toEqual({ kind: 'reset', targetPos: 0 }); + expect( + secondTurn + .filter((call) => call.kind === 'prefill') + .map((call) => (call as { text: string }).text) + .join('') + ).toContain('first question'); + }); + + it('forwards stop() to the runner', async () => { + const session = tracked(await createLLMChatSession(config)); + + session.stop(); + + expect(fakeJsi.runnerCalls()).toContainEqual({ kind: 'stop' }); + }); +}); + +describe('createLLMChatSession — tool calling', () => { + const callTool = (name: string, args: Record = {}): ToolCall => ({ + id: `call-${name}`, + type: 'function', + function: { name, arguments: args }, + }); + + /** Parses the first line of a response as `TOOL `, and nothing else. */ + const parseToolCalls = (text: string): ToolParserResult | undefined => { + const match = /^TOOL (\w+)/.exec(text.trim()); + if (!match) return { toolCalls: [], textContent: text }; + return { toolCalls: [callTool(match[1]!)], textContent: '' }; + }; + + const weather = { + type: 'function', + function: { name: 'weather', description: 'current weather' }, + execute: jest.fn(async () => 'sunny'), + }; + + beforeEach(() => weather.execute.mockClear()); + + it('runs the tool and feeds its result back for a second generation', async () => { + fakeJsi.registerLLMRunner(MODEL_PATH, { + generations: [{ response: 'TOOL weather' }, { response: 'it is sunny' }], + }); + const session = tracked( + await createLLMChatSession(config, { toolOpts: { tools: [weather], parseToolCalls } }) + ); + + const result = await session.sendMessage('what is the weather'); + + expect(weather.execute).toHaveBeenCalledTimes(1); + expect(session.getHistory().map((message) => message.role)).toEqual([ + 'user', + 'assistant', + 'tool', + 'assistant', + ]); + expect(result.messages.at(-1)!.content).toBe('it is sunny'); + expect(result.finishReason).toBe('stop'); + // One generation per turn of the loop. + expect(result.stats).toHaveLength(2); + }); + + it('records the tool result against the call that asked for it', async () => { + fakeJsi.registerLLMRunner(MODEL_PATH, { + generations: [{ response: 'TOOL weather' }, { response: 'it is sunny' }], + }); + const session = tracked( + await createLLMChatSession(config, { toolOpts: { tools: [weather], parseToolCalls } }) + ); + + await session.sendMessage('what is the weather'); + + expect(session.getHistory()[2]).toMatchObject({ + role: 'tool', + name: 'weather', + toolCallId: 'call-weather', + content: 'sunny', + }); + }); + + it('reports an unknown tool back to the model rather than throwing', async () => { + fakeJsi.registerLLMRunner(MODEL_PATH, { + generations: [{ response: 'TOOL missing' }, { response: 'sorry' }], + }); + const session = tracked( + await createLLMChatSession(config, { toolOpts: { tools: [weather], parseToolCalls } }) + ); + + await session.sendMessage('hi'); + + expect(session.getHistory()[2]!.content).toMatch(/not recognized|not available/); + }); + + it('reports a throwing tool back to the model rather than failing the turn', async () => { + fakeJsi.registerLLMRunner(MODEL_PATH, { + generations: [{ response: 'TOOL weather' }, { response: 'sorry' }], + }); + weather.execute.mockRejectedValueOnce(new Error('the service is down')); + const session = tracked( + await createLLMChatSession(config, { toolOpts: { tools: [weather], parseToolCalls } }) + ); + + const result = await session.sendMessage('hi'); + + expect(session.getHistory()[2]!.content).toMatch(/the service is down/); + expect(result.finishReason).toBe('stop'); + }); + + it('gives up after maxToolTurns rather than looping forever', async () => { + // A model that only ever asks for the tool again. + fakeJsi.registerLLMRunner(MODEL_PATH, { generations: [{ response: 'TOOL weather' }] }); + const session = tracked( + await createLLMChatSession(config, { + toolOpts: { tools: [weather], parseToolCalls, maxToolTurns: 3 }, + }) + ); + + const result = await session.sendMessage('hi'); + + expect(result.finishReason).toBe('maxToolTurns'); + expect(weather.execute).toHaveBeenCalledTimes(3); + }); +}); + +describe('createLLMChatSession — failure', () => { + it('rolls the history and the KV cache back when a turn fails', async () => { + const session = tracked(await createLLMChatSession(config)); + await session.sendMessage('first question'); + const historyBefore = session.getHistory(); + const posBefore = session.getKVCacheState().pos; + + // A tool parser that throws stands in for any mid-turn failure: the turn is + // already past its prefill when it happens. + const failing = tracked( + await createLLMChatSession(config, { + toolOpts: { + tools: [], + parseToolCalls: () => { + throw new Error('parser blew up'); + }, + }, + }) + ); + await expect(failing.sendMessage('doomed')).rejects.toThrow('parser blew up'); + + expect(failing.getHistory()).toEqual([]); + // The session that did not fail is untouched. + expect(session.getHistory()).toEqual(historyBefore); + expect(session.getKVCacheState().pos).toBe(posBefore); + }); + + it('is still usable after a failed turn', async () => { + let shouldFail = true; + const session = tracked( + await createLLMChatSession(config, { + toolOpts: { + tools: [], + parseToolCalls: (text) => { + if (shouldFail) throw new Error('parser blew up'); + return { toolCalls: [], textContent: text }; + }, + }, + }) + ); + await expect(session.sendMessage('doomed')).rejects.toThrow(); + + shouldFail = false; + const result = await session.sendMessage('second try'); + + expect(session.getHistory().map((message) => message.content)).toEqual([ + 'second try', + 'hello there ', + ]); + expect(result.finishReason).toBe('stop'); + }); + + it('surfaces a missing runner rather than resolving with a broken session', async () => { + fakeJsi.reset(); + writeTokenizerConfig(); + + await expect(createLLMChatSession(config)).rejects.toThrow(/no runner registered/); + allowNativeLeaks(); // see `tasks/constructionFailure.test.ts` + }); +}); diff --git a/packages/react-native-executorch/__tests__/tasks/paddleOcr.test.ts b/packages/react-native-executorch/__tests__/tasks/paddleOcr.test.ts new file mode 100644 index 0000000000..35bbf88e20 --- /dev/null +++ b/packages/react-native-executorch/__tests__/tasks/paddleOcr.test.ts @@ -0,0 +1,319 @@ +/** + * The PP-OCRv6 optical character recognition pipeline. + * + * One `.pte` exporting two methods, run in sequence: `detect` produces a DBNet + * probability map that is decoded into oriented quads, and `recognize` reads + * each quad through a CTC head. What is contract rather than weights — the two + * signatures and the `W = 8 * T` relation between them, the charset having to + * match the recognizer's vocabulary, the CTC collapse, the confidence filter, + * the reading order the detections come back in, and the tensors every quad + * allocates and frees — is what this suite covers. + * + * The fake decoder finds connected regions of the probability map, so a test + * paints the map it wants detections from rather than stubbing the decode. + */ +import { RangeDim, constraint, f32, method } from '../../src/core/schema'; +import { createPaddleOcr } from '../../src/extensions/cv/tasks/paddleOcr'; +import type { ImageBuffer } from '../../src/extensions/cv/image'; +import { fakeJsi } from '../support/fakeJsi'; +import { fakeFs } from '../support/blobUtilMock'; +import { exported } from '../support/fixtures'; +import { tracked } from '../support/lifetime'; +import { allowNativeLeaks } from '../support/setup'; + +const MODEL_PATH = '/models/paddle_ocr.pte'; +const CHARSET_PATH = '/models/charset.json'; + +// Fixed by the export: SVTR reduces the recognizer width onto the CTC time axis +// by exactly 8, which the spec asserts as a runtime constraint. +const CTC_STRIDE = 8; +const REC_HEIGHT = 8; +const CHARSET = ['a', 'b', 'c']; +const VOCAB = CHARSET.length + 1; // plus the CTC blank at index 0 + +const DET_SIZES = RangeDim(16, 64, 16); +const REC_WIDTHS = RangeDim(CTC_STRIDE, CTC_STRIDE * 8, CTC_STRIDE); + +const schema = (vocab = VOCAB) => + exported({ + ...method('detect', [f32(1, 3, DET_SIZES, DET_SIZES)], [f32(1, 1, DET_SIZES, DET_SIZES)]), + ...method( + 'recognize', + [f32(1, 3, REC_HEIGHT, REC_WIDTHS)], + [f32(1, RangeDim(1, 8), vocab)], + [ + constraint.linear( + { paramSide: 'input', tensorIdx: 0, dimIdx: 3 }, + { paramSide: 'output', tensorIdx: 0, dimIdx: 1 }, + CTC_STRIDE + ), + ] + ), + }); + +/** A plain white RGB page, so the detector's input is well defined. */ +const page = (width = 32, height = 32): ImageBuffer => ({ + data: new Uint8Array(width * height * 3).fill(255), + width, + height, + format: 'rgb', + layout: 'hwc', +}); + +type Region = { x0: number; y0: number; x1: number; y1: number }; + +/** + * Builds an `execute` for the fused model: `detect` paints `regions` into the + * probability map, and `recognize` emits the CTC logits for the next scripted + * word, one word per call in order. + * @param regions Rectangles of the probability map to mark as text. + * @param words The text each recognized region decodes to, in order. + * @param confidence Peak probability written for a recognized character. + * @returns The execute implementation. + */ +const detectsAndReads = (regions: Region[], words: string[], confidence = 0.9) => { + let recognizeCall = 0; + return ( + methodName: string, + _inputs: readonly unknown[], + outputs: readonly { + numel: number; + shape: readonly number[]; + setElement: (i: number, v: number) => void; + }[] + ) => { + const output = outputs[0]!; + if (methodName === 'detect') { + const width = output.shape[3]!; + for (let i = 0; i < output.numel; i++) output.setElement(i, 0); + for (const { x0, y0, x1, y1 } of regions) { + for (let y = y0; y <= y1; y++) { + for (let x = x0; x <= x1; x++) output.setElement(y * width + x, 1); + } + } + return; + } + + // `recognize`: [1, timesteps, vocab]. Each character takes one timestep, + // the rest stay on the blank, so the greedy CTC collapse reproduces the + // word exactly. + const [, timesteps, vocab] = output.shape as [number, number, number]; + const word = words[Math.min(recognizeCall, words.length - 1)] ?? ''; + recognizeCall++; + for (let t = 0; t < timesteps; t++) { + const character = word[t]; + const index = character === undefined ? 0 : CHARSET.indexOf(character) + 1; + for (let v = 0; v < vocab; v++) { + output.setElement(t * vocab + v, v === index ? confidence : 0); + } + } + }; +}; + +const config = { + modelPath: MODEL_PATH, + charsetPath: CHARSET_PATH, + modelOpts: { defaultConfidenceThreshold: 0.5 }, +}; + +const register = (regions: Region[], words: string[], confidence?: number) => + fakeJsi.registerModel(MODEL_PATH, { + schema: schema(), + execute: detectsAndReads(regions, words, confidence), + }); + +beforeEach(() => { + fakeFs.write(CHARSET_PATH, JSON.stringify(CHARSET)); + register([{ x0: 2, y0: 6, x1: 26, y1: 9 }], ['abc']); +}); + +describe('createPaddleOcr — the model contract', () => { + it('accepts the fused detect/recognize export', async () => { + const ocr = tracked(await createPaddleOcr(config)); + + expect(ocr.recognizeCharacters).toBeInstanceOf(Function); + expect(ocr.recognizeCharactersWorklet).toBeInstanceOf(Function); + }); + + it('rejects an export missing the recognize method', async () => { + fakeJsi.registerModel(MODEL_PATH, { + schema: exported( + method('detect', [f32(1, 3, DET_SIZES, DET_SIZES)], [f32(1, 1, DET_SIZES, DET_SIZES)]) + ), + }); + + await expect(createPaddleOcr(config)).rejects.toThrow(/recognize/); + allowNativeLeaks(); // see `tasks/constructionFailure.test.ts` + }); + + it('rejects a recognizer whose width is not 8x its CTC time axis', async () => { + // The pipeline pre-allocates the probability tensor from the input width, + // so a model with a different stride would silently mis-shape every read. + fakeJsi.registerModel(MODEL_PATH, { + schema: exported({ + ...method('detect', [f32(1, 3, DET_SIZES, DET_SIZES)], [f32(1, 1, DET_SIZES, DET_SIZES)]), + ...method( + 'recognize', + [f32(1, 3, REC_HEIGHT, REC_WIDTHS)], + [f32(1, RangeDim(1, 8), VOCAB)] + ), + }), + }); + + await expect(createPaddleOcr(config)).rejects.toThrow(/constraint/i); + allowNativeLeaks(); // see `tasks/constructionFailure.test.ts` + }); + + it('rejects a charset that does not cover the recognizer vocabulary', async () => { + fakeFs.write(CHARSET_PATH, JSON.stringify([...CHARSET, 'd'])); + + await expect(createPaddleOcr(config)).rejects.toThrow(/charset size/); + allowNativeLeaks(); // see `tasks/constructionFailure.test.ts` + }); + + it('surfaces a missing charset file', async () => { + fakeFs.remove(CHARSET_PATH); + + await expect(createPaddleOcr(config)).rejects.toThrow(/ENOENT/); + allowNativeLeaks(); // see `tasks/constructionFailure.test.ts` + }); + + it('releases the model on dispose', async () => { + const ocr = await createPaddleOcr(config); + + ocr.dispose(); + + expect(fakeJsi.liveModels()).toEqual([]); + expect(fakeJsi.liveTensors()).toBe(0); + }); +}); + +describe('createPaddleOcr — recognition', () => { + it('reads a detected region and reports its text and quad', async () => { + const ocr = tracked(await createPaddleOcr(config)); + + const [detection, ...rest] = await ocr.recognizeCharacters(page()); + + expect(rest).toEqual([]); + expect(detection!.text).toBe('abc'); + expect(detection!.confidence).toBeCloseTo(0.9); + expect(detection!.quad).toHaveLength(4); + }); + + it('runs detect once and recognize once per region', async () => { + register( + [ + { x0: 2, y0: 2, x1: 10, y1: 6 }, + { x0: 2, y0: 16, x1: 10, y1: 20 }, + ], + ['ab', 'ca'] + ); + const ocr = tracked(await createPaddleOcr(config)); + + await ocr.recognizeCharacters(page()); + + expect(fakeJsi.executions().map(({ methodName }) => methodName)).toEqual([ + 'detect', + 'recognize', + 'recognize', + ]); + }); + + it('returns the regions in reading order, top to bottom', async () => { + register( + [ + { x0: 2, y0: 18, x1: 12, y1: 24 }, + { x0: 2, y0: 2, x1: 12, y1: 8 }, + ], + ['ab', 'ca'] + ); + const ocr = tracked(await createPaddleOcr(config)); + + const detections = await ocr.recognizeCharacters(page()); + + // The decoder walks the map top-down, so the upper region is recognized + // first; what matters is that the result is ordered by position either way. + const tops = detections.map((detection) => Math.min(...detection.quad.map((p) => p.y))); + expect(tops).toEqual([...tops].sort((a, b) => a - b)); + }); + + it('finds nothing on a page the detector leaves blank', async () => { + register([], []); + const ocr = tracked(await createPaddleOcr(config)); + + expect(await ocr.recognizeCharacters(page())).toEqual([]); + }); + + it('collapses a CTC run of the same character to one glyph', async () => { + // The recognizer emits `a` on every timestep; the decode has to yield 'a', + // not one character per timestep. + fakeJsi.registerModel(MODEL_PATH, { + schema: schema(), + execute: (methodName, inputs, outputs) => { + if (methodName === 'detect') { + detectsAndReads([{ x0: 2, y0: 6, x1: 26, y1: 9 }], [])(methodName, inputs, outputs); + return; + } + const output = outputs[0]!; + const [, timesteps, vocab] = output.shape as [number, number, number]; + for (let t = 0; t < timesteps; t++) { + for (let v = 0; v < vocab; v++) output.setElement(t * vocab + v, v === 1 ? 0.9 : 0); + } + }, + }); + const ocr = tracked(await createPaddleOcr(config)); + + expect((await ocr.recognizeCharacters(page()))[0]!.text).toBe('a'); + }); + + it('drops a region whose confidence is below the model default', async () => { + register([{ x0: 2, y0: 6, x1: 26, y1: 9 }], ['abc'], 0.2); + const ocr = tracked(await createPaddleOcr(config)); + + expect(await ocr.recognizeCharacters(page())).toEqual([]); + }); + + it('lets a call override the model confidence threshold', async () => { + register([{ x0: 2, y0: 6, x1: 26, y1: 9 }], ['abc'], 0.2); + const ocr = tracked(await createPaddleOcr(config)); + + const detections = await ocr.recognizeCharacters(page(), { confidenceThreshold: 0.1 }); + + expect(detections.map((detection) => detection.text)).toEqual(['abc']); + }); + + it('reports quads in original image pixels, not detector pixels', async () => { + // A page larger than the detector's widest input is scaled down before + // detection; the quads have to come back mapped into the page's own space. + register([{ x0: 4, y0: 4, x1: 20, y1: 12 }], ['abc']); + const ocr = tracked(await createPaddleOcr(config)); + + const [detection] = await ocr.recognizeCharacters(page(256, 256)); + + const xs = detection!.quad.map((p) => p.x); + expect(Math.max(...xs)).toBeGreaterThan(64); // beyond any detector-space coordinate + expect(Math.max(...xs)).toBeLessThanOrEqual(256); + }); + + it('runs synchronously on the caller thread through the worklet variant', async () => { + const ocr = tracked(await createPaddleOcr(config)); + + expect(ocr.recognizeCharactersWorklet(page())[0]!.text).toBe('abc'); + }); + + it('frees every per-call tensor, however many regions were found', async () => { + register( + [ + { x0: 2, y0: 2, x1: 10, y1: 6 }, + { x0: 2, y0: 16, x1: 10, y1: 20 }, + ], + ['ab', 'ca'] + ); + const ocr = tracked(await createPaddleOcr(config)); + + await ocr.recognizeCharacters(page()); + + // The pipeline holds nothing between calls: the model is the only resource. + expect(fakeJsi.liveTensors()).toBe(0); + }); +}); diff --git a/packages/react-native-executorch/__tests__/tasks/privacyFilter.test.ts b/packages/react-native-executorch/__tests__/tasks/privacyFilter.test.ts new file mode 100644 index 0000000000..561a6d83c6 --- /dev/null +++ b/packages/react-native-executorch/__tests__/tasks/privacyFilter.test.ts @@ -0,0 +1,310 @@ +/** + * The privacy filter pipeline. + * + * Unlike the other stateful pipelines, nothing here depends on real weights: + * the model contributes one logit row per token and everything that turns those + * rows into entity spans — the BIOES grammar, the Viterbi decode, the sliding + * window and its overlap policy, the character offsets — is TypeScript. So this + * suite drives the whole thing end to end, with an `execute` that emits the + * label sequence each test wants to decode. + * + * The window logic is the part worth pinning down. A model exported with a + * dynamic sequence dimension runs each window at the token count it actually + * holds, rounded onto the grid the export accepts; a statically exported one + * only accepts its single length and pads up to it. Both have to label every + * token of an input longer than one window, and neither may drop the tail. + */ +import { RangeDim, f32, i64, method, constraint } from '../../src/core/schema'; +import { createPrivacyFilter } from '../../src/extensions/nlp/tasks/privacyFilter'; +import { fakeJsi, type FakeExecute } from '../support/fakeJsi'; +import { exported } from '../support/fixtures'; +import { tracked } from '../support/lifetime'; +import { allowNativeLeaks } from '../support/setup'; + +const MODEL_PATH = '/models/privacy-filter.pte'; +const TOKENIZER_PATH = '/models/tokenizer.json'; +const PAD_TOKEN_ID = 9; + +// One entity type is enough to exercise the grammar: BIOES is per-entity, and a +// second type only repeats the same four transitions. +const LABELS = ['O', 'B-person', 'I-person', 'E-person', 'S-person'] as const; +const L = { O: 0, B: 1, I: 2, E: 3, S: 4 } as const; + +const WORDS = ['call', 'ada', 'lovelace', 'today', 'or', 'ask', 'grace', 'now', ''] as const; + +const registerTokenizer = () => + fakeJsi.registerTokenizer(TOKENIZER_PATH, { tokens: WORDS, specialIds: [PAD_TOKEN_ID] }); + +const dynamicSchema = (min: number, max: number, step = 1) => + exported( + method( + 'forward', + [i64(1, RangeDim(min, max, step)), i64(1, RangeDim(min, max, step))], + [f32(1, RangeDim(min, max, step), LABELS.length)], + [ + constraint.equality( + { paramSide: 'input', tensorIdx: 0, dimIdx: 1 }, + { paramSide: 'input', tensorIdx: 1, dimIdx: 1 }, + { paramSide: 'output', tensorIdx: 0, dimIdx: 1 } + ), + ] + ) + ); + +const staticSchema = (length: number) => + exported(method('forward', [i64(1, length), i64(1, length)], [f32(1, length, LABELS.length)])); + +/** + * An `execute` that gives each token the label `labelFor` picks for it, by + * writing a large logit into that column. Tokens are identified by their id + * rather than by position, so the same fixture serves every window. + * @param labelFor Maps a token id to the label index it should decode to. + * @returns The execute implementation. + */ +const labels = (labelFor: (tokenId: number) => number): FakeExecute => { + return (_methodName, inputs, outputs) => { + const ids = inputs[0] as { numel: number; getElement: (i: number) => number }; + const logits = outputs[0]!; + const length = ids.numel; + for (let position = 0; position < length; position++) { + const label = labelFor(ids.getElement(position)); + for (let column = 0; column < LABELS.length; column++) { + logits.setElement(position * LABELS.length + column, column === label ? 10 : 0); + } + } + }; +}; + +/** Every token labelled `O`, so a test can focus on the shapes that were run. */ +const allBackground = labels(() => L.O); + +/** The sequence length of every `forward` the fake model was asked to run. */ +const runLengths: number[] = []; +beforeEach(() => runLengths.splice(0)); + +/** Wraps an `execute`, recording the sequence length each call was given. */ +const recordingLengths = + (inner: FakeExecute): FakeExecute => + (methodName, inputs, outputs) => { + runLengths.push((inputs[0] as { shape: readonly number[] }).shape[1]!); + inner(methodName, inputs, outputs); + }; + +const config = { + modelPath: MODEL_PATH, + tokenizerPath: TOKENIZER_PATH, + modelOpts: { labelNames: LABELS, padTokenId: PAD_TOKEN_ID }, +}; + +beforeEach(registerTokenizer); + +describe('createPrivacyFilter — the label space', () => { + beforeEach(() => { + fakeJsi.registerModel(MODEL_PATH, { schema: dynamicSchema(2, 8) }); + }); + + it.each([ + ['an empty label list', []], + ["a list that does not start with 'O'", ['B-person', 'O']], + ])('rejects %s', async (_label, labelNames) => { + await expect( + createPrivacyFilter({ ...config, modelOpts: { labelNames, padTokenId: PAD_TOKEN_ID } }) + ).rejects.toThrow(/labelNames/); + }); + + it('rejects a model whose logits are wider than the label space', async () => { + fakeJsi.registerModel(MODEL_PATH, { + schema: exported( + method( + 'forward', + [i64(1, RangeDim(2, 8)), i64(1, RangeDim(2, 8))], + [f32(1, RangeDim(2, 8), LABELS.length + 1)] + ) + ), + }); + + await expect(createPrivacyFilter(config)).rejects.toThrow( + /output #0 Tensor dim #2: Constant dimension mismatch/ + ); + allowNativeLeaks(); // see `tasks/constructionFailure.test.ts` + }); + + it('rejects a window too short to hold a span and its context', async () => { + fakeJsi.registerModel(MODEL_PATH, { schema: staticSchema(1) }); + + await expect(createPrivacyFilter(config)).rejects.toThrow(/at least 2 tokens/); + allowNativeLeaks(); // see `tasks/constructionFailure.test.ts` + }); +}); + +describe('createPrivacyFilter — decoding', () => { + beforeEach(() => { + fakeJsi.registerModel(MODEL_PATH, { + schema: dynamicSchema(2, 8), + // "ada lovelace" is a two-token person; everything else is background. + execute: labels((id) => (id === 1 ? L.B : id === 2 ? L.E : L.O)), + }); + }); + + it('extracts a multi-token span with its text and token range', async () => { + const filter = tracked(await createPrivacyFilter(config)); + + const entities = await filter.detectPii('call ada lovelace today'); + + expect(entities).toEqual([ + { + label: 'person', + text: 'ada lovelace', + startToken: 1, + endToken: 3, + charStart: expect.any(Number), + charEnd: expect.any(Number), + }, + ]); + }); + + it('reports character offsets that slice the span back out of the input', async () => { + const filter = tracked(await createPrivacyFilter(config)); + const input = 'call ada lovelace today'; + + const [entity] = await filter.detectPii(input); + + expect(input.slice(entity!.charStart, entity!.charEnd).trim()).toBe('ada lovelace'); + }); + + it('returns nothing for an empty input, without running the model', async () => { + const filter = tracked(await createPrivacyFilter(config)); + + expect(await filter.detectPii('')).toEqual([]); + expect(fakeJsi.executions()).toEqual([]); + }); + + it('returns nothing when every token decodes to background', async () => { + fakeJsi.registerModel(MODEL_PATH, { + schema: dynamicSchema(2, 8), + execute: recordingLengths(allBackground), + }); + const filter = tracked(await createPrivacyFilter(config)); + + expect(await filter.detectPii('call ada lovelace today')).toEqual([]); + }); + + it('splits two adjacent spans of the same type at the second opener', async () => { + // `B` after `E` starts a new span rather than extending the previous one — + // otherwise two people standing next to each other read as one name. + fakeJsi.registerModel(MODEL_PATH, { + schema: dynamicSchema(2, 8), + execute: labels((id) => (id === 1 || id === 6 ? L.S : L.O)), + }); + const filter = tracked(await createPrivacyFilter(config)); + + const entities = await filter.detectPii('ask ada grace now'); + + expect(entities.map((entity) => entity.text)).toEqual(['ada', 'grace']); + }); + + it('runs synchronously on the caller thread through the worklet variant', async () => { + const filter = tracked(await createPrivacyFilter(config)); + + expect(filter.detectPiiWorklet('call ada lovelace today').map((e) => e.text)).toEqual([ + 'ada lovelace', + ]); + }); + + it('releases the model and the tokenizer on dispose', async () => { + const filter = await createPrivacyFilter(config); + + filter.dispose(); + + expect(fakeJsi.liveModels()).toEqual([]); + expect(fakeJsi.liveTokenizers()).toEqual([]); + expect(fakeJsi.liveTensors()).toBe(0); + }); +}); + +describe('createPrivacyFilter — windowing', () => { + // Eight tokens, so a four-token window has to slide to reach the tail. + const EIGHT_TOKENS = 'call ada lovelace today or ask grace now'; + + it('labels every token of an input longer than one window', async () => { + fakeJsi.registerModel(MODEL_PATH, { + schema: dynamicSchema(2, 4), + // The last token of the input is the only entity, so it is only found if + // the final window is actually run and its tail is not discarded. + execute: labels((id) => (id === 7 ? L.S : L.O)), + }); + const filter = tracked(await createPrivacyFilter(config)); + + const entities = await filter.detectPii(EIGHT_TOKENS); + + expect(entities.map((entity) => entity.text)).toEqual(['now']); + expect(entities[0]!.startToken).toBe(7); + }); + + it('overlaps consecutive windows rather than tiling them', async () => { + fakeJsi.registerModel(MODEL_PATH, { schema: dynamicSchema(2, 4), execute: allBackground }); + const filter = tracked(await createPrivacyFilter(config)); + + await filter.detectPii(EIGHT_TOKENS); + + // Tiling eight tokens into a four-token window would take two passes; the + // 50% overlap that gives boundary tokens a centred second look takes four. + expect(fakeJsi.executions().length).toBeGreaterThan(2); + }); + + it('runs a dynamic export at the token count each window holds', async () => { + fakeJsi.registerModel(MODEL_PATH, { + schema: dynamicSchema(2, 8), + execute: recordingLengths(allBackground), + }); + const filter = tracked(await createPrivacyFilter(config)); + + await filter.detectPii('call ada lovelace'); + + // Three tokens, a window of eight: a dynamic export pays for three. + expect(runLengths).toEqual([3]); + }); + + it('rounds a dynamic run up onto the grid the export accepts', async () => { + // Exported for 2, 6, 10, …: a three-token input cannot run at 3. + fakeJsi.registerModel(MODEL_PATH, { + schema: dynamicSchema(2, 10, 4), + execute: recordingLengths(allBackground), + }); + const filter = tracked(await createPrivacyFilter(config)); + + await filter.detectPii('call ada lovelace'); + + expect(runLengths).toEqual([6]); + }); + + it('pads a static export up to its single accepted length', async () => { + fakeJsi.registerModel(MODEL_PATH, { + schema: staticSchema(8), + execute: recordingLengths(allBackground), + }); + const filter = tracked(await createPrivacyFilter(config)); + + await filter.detectPii('call ada lovelace'); + + expect(runLengths).toEqual([8]); + }); + + it('masks the padding a static export is fed', async () => { + const masks: number[][] = []; + fakeJsi.registerModel(MODEL_PATH, { + schema: staticSchema(8), + execute: (methodName, inputs, outputs) => { + const mask = inputs[1] as { numel: number; getElement: (i: number) => number }; + masks.push([...Array(mask.numel).keys()].map((i) => mask.getElement(i))); + allBackground(methodName, inputs, outputs); + }, + }); + const filter = tracked(await createPrivacyFilter(config)); + + await filter.detectPii('call ada lovelace'); + + // Three real tokens attended to, five padding slots masked out. + expect(masks).toEqual([[1, 1, 1, 0, 0, 0, 0, 0]]); + }); +});