Skip to content

[RNE Rewrite] test(ts): add API tests for the TypeScript surface - #1355

Merged
msluszniak merged 8 commits into
rne-rewritefrom
@ms/api-tests
Aug 27, 2026
Merged

[RNE Rewrite] test(ts): add API tests for the TypeScript surface#1355
msluszniak merged 8 commits into
rne-rewritefrom
@ms/api-tests

Conversation

@msluszniak

@msluszniak msluszniak commented Aug 7, 2026

Copy link
Copy Markdown
Member

Description

Adds TS API tests:

  • hooks
  • task pipelines
  • core primitives
  • the resource fetcher
  • model registry.

Also adds necessary skills.

To find details about this approach please follow: __tests__/README.md.

No source changes: the three contract violations the suites originally surfaced (SpecMatch.dim, the missing 'worklet' directive on getRegisteredBackends, and randomNormal's millisecond-resolution default seed) have all since been fixed on rne-rewrite, so src/ is untouched by this PR.

During the testing I spotted a problem that is addressed in #1386.

Introduces a breaking change?

  • Yes
  • No

Type of change

  • Bug fix (change which fixes an issue)
  • New feature (change which adds functionality)
  • Documentation update (improves or adds clarity to existing documentation)
  • Other (chores, tests, code style improvements etc.)

Tested on

  • iOS
  • Android

Testing instructions

yarn workspace react-native-executorch test
yarn typecheck
yarn lint

Expected: 28 suites, 3072 tests, 4 snapshots, 0 skipped; typecheck and lint clean. yarn prepare still emits only src/ into lib/.

Screenshots

N/A

Related issues

Closes #1352.

Checklist

  • I have performed a self-review of my code
  • I have commented my code, particularly in hard-to-understand areas
  • I have updated the documentation accordingly
  • My changes generate no new warnings

Additional notes

Layout

Path Contents
core/ tensor, model, runtime, the coded error type, and the spec matcher (symbol binding, variants, runtime constraints, authoring errors)
fetcher/ caching, forced re-download, byte-weighted progress, failures, cancellation, requests shared between concurrent callers, iOS resume, the Android DownloadManager backend, telemetry
tasks/ one suite per pipeline, plus the shared construction-failure behavior
hooks/ useModel, useResourceDownload, and the task hooks end to end
extensions/ box and point scaling, box decoding, seeded generators
api/ export snapshot, registry rules, label constants, source-level conventions
support/ the fake runtime, the mocks, the fixtures

Deliberately not covered: the numerical behavior of the native operators (that is cpp/tests/, and duplicating it here would only test the fake); the parts of a pipeline whose behavior depends on real weights: Whisper's decode loop, the VAD rolling window and the SDXS diffusion step get schema acceptance, rejection and full disposal instead; and the worklet thread hop, since worklets run inline. The 'worklet' directive convention that makes that hop possible is enforced by parsing src/ with the TypeScript compiler.

That line is drawn per pipeline, not per suite. The privacy filter's logits are weights but its BIOES decode and sliding window are not, so they run end to end; the LLM's generation belongs to the native runner but the chat session's history, KV cache bookkeeping and tool loop are covered against a scripted one; Kokoro's waveform is weights but its argument validation, chunking and streaming are not; PaddleOCR's probability map is weights but the quad decode, CTC collapse and reading order run over a map the test paints. The fake runtime grew llm.createLLMRunner, speech.createPhonemizer, math.gather, cv.extractDbnetTextQuads, cv.rectifyQuad and fs.readFile to make that possible.

@msluszniak msluszniak self-assigned this Aug 7, 2026
@msluszniak msluszniak added the test Issue and PR related to tests or testing infrastructure label Aug 7, 2026
@msluszniak msluszniak linked an issue Aug 7, 2026 that may be closed by this pull request
@msluszniak
msluszniak marked this pull request as draft August 7, 2026 14:36
@msluszniak
msluszniak force-pushed the @ms/api-tests branch 2 times, most recently from 499a2ad to 5e6e7e9 Compare August 26, 2026 19:06
@msluszniak
msluszniak marked this pull request as ready for review August 26, 2026 20:30
@msluszniak
msluszniak requested a review from barhanc August 26, 2026 20:30
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.
- `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.
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<Task>` 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.
- 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.
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.
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.
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.
`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.
@msluszniak
msluszniak merged commit 6a240b9 into rne-rewrite Aug 27, 2026
4 checks passed
@msluszniak
msluszniak deleted the @ms/api-tests branch August 27, 2026 08:01
msluszniak added a commit that referenced this pull request Aug 27, 2026
…ails (#1386)

## Description

Fixes the finding recorded in #1355: a `create<Task>` that throws
part-way through construction abandoned everything it had already
allocated.

A factory allocates as it goes, and only hands back a `dispose` at the
very end. Anything that threw in between left the caller with no
reference to what was already there, and native memory is not garbage
collected, so it stayed alive for the rest of the process. Depending on
the task that is a model, a tokenizer, a phonemizer, an LLM runner, or a
whole nested pipeline (Whisper owns both a tokenizer and a VAD).
`useModel` re-runs its factory whenever the config changes, so an app
pointed at a mismatched model leaked a full resource set per attempt.

`src/core/lifetime.ts` adds `createResourceScope`, which gives a factory
one teardown path for both outcomes: it tracks each resource as it is
created, releases them in reverse order, and the same function becomes
the pipeline's `dispose`. Every factory now allocates through a scope
and wraps its body in `try`/`catch`:

```typescript
const scope = createResourceScope();
const dispose = scope.dispose;

try {
  const model = scope.track(await wrapAsync(loadModel, runtime)(modelPath));
  const { dims } = validateSpec(model.schema, { ... }); // may throw
  const tensors = [tensor('float32', outShape)] as const;
  tensors.forEach(scope.track);
  return { classify, classifyWorklet, dispose };
} catch (error) {
  dispose();
  throw error;
}
```

`createKokoroTextToSpeech` already did this with a local array and moves
to the shared helper. That also closes a smaller hole it had: its two
models were loaded before the `try` block, so one load rejecting
stranded the other. Parallel loads elsewhere now track inside each
promise for the same reason.

`createTokenizer` is unchanged. It loads a tokenizer and returns with
nothing in between that can throw, so it has no window to leak through.

The helper is internal, so the public export surface is unchanged.

### Introduces a breaking change?

- [ ] Yes
- [x] No

### Type of change

- [x] Bug fix (change which fixes an issue)
- [ ] New feature (change which adds functionality)
- [ ] Documentation update (improves or adds clarity to existing
documentation)
- [ ] Other (chores, tests, code style improvements etc.)

### Tested on

- [ ] iOS
- [ ] Android

### Testing instructions

```sh
yarn workspace react-native-executorch test
yarn typecheck
yarn lint
```

Expected: 28 suites, 3117 tests, 4 snapshots, 0 skipped; typecheck and
lint clean.

To see the tests bite, stash `src/` and re-run: every
construction-failure case fails.

### Screenshots

N/A

### Related issues

Follows up the finding recorded in #1355.

### Checklist

- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [x] I have updated the documentation accordingly
- [x] My changes generate no new warnings

### Additional notes

Stacked on `@ms/api-tests` (#1355), because that is where the suite
recording this behavior lives. Merge #1355 first and this retargets to
`rne-rewrite` cleanly.

`tasks/constructionFailure.test.ts` was written to record the leak as
current behavior so it would fail loudly the day a factory started
cleaning up. That is this PR, so it now asserts the opposite, and covers
all fifteen factories rather than five. It checks every kind of handle,
so a failure names the factory and the resource rather than just
reporting that something leaked.

The per-pipeline suites and `hooks/taskHooks.test.ts` drop the
`allowNativeLeaks()` calls they needed for the same reason. Nothing in
the suite leaks any more, so the setup file's global leak check now
asserts this on every construction-failure test for free.

The diff is large mostly because indenting a factory body inside `try`
touches every line of it. The behavioral change per file is the scope,
the `track` calls and the `catch`.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

test Issue and PR related to tests or testing infrastructure

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[RNE Rewrite] Add API tests

2 participants