diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f16d483..a08640f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,17 +35,17 @@ jobs: - run: pnpm install --frozen-lockfile - # This enables task distribution via Nx Cloud. - # Learn more at https://nx.dev/docs/reference/nx-cloud-cli#npx-nxcloud-startcirun - - run: pnpm exec nx start-ci-run --distribute-on="3 linux-medium-js" --stop-agents-after="e2e" - + # This enables task distribution via Nx Cloud. + # Learn more at https://nx.dev/docs/reference/nx-cloud-cli#npx-nxcloud-startcirun + - run: pnpm exec nx start-ci-run --distribute-on="3 linux-medium-js" --stop-agents-after="e2e" + # Prepend any command with "nx record --" to record its logs to Nx Cloud - # - run: pnpm exec nx record -- pnpm exec nx format:check --base="remotes/origin/main" - # - run: pnpm exec nx run-many -t lint test build typecheck e2e + # - run: pnpm exec nx record -- pnpm exec nx format:check --base="remotes/origin/main" + # - run: pnpm exec nx run-many -t lint test build typecheck e2e # Nx Cloud recommends fixes for failures to help you get CI green faster. Learn more: https://nx.dev/docs/features/ci-features/self-healing-ci - # - run: pnpm exec nx fix-ci + # - run: pnpm exec nx fix-ci # if: always() - # - run: pnpm exec nx format:check --base="remotes/origin/main" + # - run: pnpm exec nx format:check --base="remotes/origin/main" # Vitest, TypeScript and the Rust fixture crate. Everything here runs on a # plain Linux runner: the .wasm fixtures are committed build outputs, so no @@ -83,7 +83,7 @@ jobs: # projects — including the test app's mocha specs, which are type-checked # here even though they can only run on a device. - name: Vitest + typecheck - run: pnpm exec nx run-many -t test typecheck + run: pnpm exec nx run-many -t test typecheck # Rust is pre-installed on GitHub's ubuntu-latest runner. When debugging # locally with `act` the slim image may not have it — install on the fly @@ -97,7 +97,7 @@ jobs: # The fixture crate: wasm exports plus the globals.wasm byte encoder. - name: Rust unit tests - run: pnpm exec nx run nativescript-wasm-fixture:test.wasm + run: pnpm exec nx run nativescript-wasm-fixture:test.wasm # globals.wasm is committed but generated, and the generator is # deterministic — so a stale copy is a hard error rather than a surprise @@ -200,8 +200,8 @@ jobs: -scheme NSCWasm3 \ -destination 'generic/platform=iOS' - # Android: regenerates the JavaCPP bindings from wasm3.h and runs the Kotlin - # wrapper's JUnit suite against a host build of wasm3 — no emulator needed. + # Android: builds the wasm3-jni Rust crate via cargo and runs the Kotlin + # wrapper's JUnit hosttest suite — no emulator needed. wasm3-android: needs: changes if: needs.changes.outputs.wasm3 == 'true' @@ -213,6 +213,7 @@ jobs: # compile wasm3, so the native build needs Node and the root node_modules. - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v5 with: node-version: 24 @@ -227,21 +228,17 @@ jobs: - uses: gradle/actions/setup-gradle@v4.4.4 with: - # The built-in wrapper validation scans the entire workspace and - # flags every gradle-wrapper.jar it finds — including the one - # bundled inside node_modules/nativescript. That jar is an npm - # package dependency, not ours to validate. Our own wrapper is - # committed in the repo at platforms/android/wasm3-android/. validate-wrappers: false - # GitHub's ubuntu-latest runner ships clang; the slim act image does not. - - name: Install build tools (if missing) + # Rust is pre-installed on ubuntu-latest; install if missing (e.g. act). + - name: Install Rust (if missing) run: | - if ! command -v clang++ >/dev/null 2>&1; then - sudo apt-get update -qq && sudo apt-get install -y -qq clang + if ! command -v cargo >/dev/null 2>&1; then + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal + echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" fi - - name: JVM host tests (JavaCPP bindings + Kotlin wrapper) + - name: JVM host tests (cargo build + Kotlin wrapper) working-directory: packages/nativescript-wasm3/platforms/android/wasm3-android run: ./gradlew :hosttest:test @@ -255,114 +252,116 @@ jobs: # place the plugin's TypeScript adapters run against the real native layer — # the NSData/NSArray marshalling on iOS, the signed byte[] handling on Android # — so it covers what neither the Swift/Kotlin suites nor a Node runner can. - # iOS: compiles the vendored WAMR C sources through SwiftPM and runs the - # XCTest suite, which exercises the runtime natively on macOS. - wamr-ios: - needs: changes - if: needs.changes.outputs.wamr == 'true' - runs-on: macos-latest - steps: - - uses: actions/checkout@v5 - - - uses: actions/setup-node@v5 - with: - node-version: 24 - - - name: Check for WAMR C sources - id: sources - run: | - if find packages/nativescript-wamr/src/vendors/wamr -name '*.c' -o -name '*.h' | grep -q .; then - echo "present=true" >> "$GITHUB_OUTPUT" - else - echo "present=false" >> "$GITHUB_OUTPUT" - echo "::warning::WAMR C sources not found — Swift steps will be skipped." - fi - - - name: Verify the synced Swift package copy is current - if: steps.sources.outputs.present == 'true' - working-directory: packages/nativescript-wamr - run: | - node tools/sync-wamr.mjs - git diff --exit-code -- . || { - echo "::error::platforms/ios/NSCWamr/Sources/CWamr is stale — run 'pnpm run sync.vendors' and commit the result." - exit 1 - } - - - name: Swift tests - if: steps.sources.outputs.present == 'true' - working-directory: packages/nativescript-wamr/platforms/ios/NSCWamr - run: swift test --disable-sandbox - - - name: Build for iOS device - if: steps.sources.outputs.present == 'true' - working-directory: packages/nativescript-wamr/platforms/ios/NSCWamr - run: | - xcodebuild build \ - -scheme NSCWamr \ - -destination 'generic/platform=iOS' - - # Android: regenerates the JavaCPP bindings from WAMR headers and runs the - # Kotlin wrapper's JUnit suite against a host build of WAMR — no emulator. - wamr-android: - needs: changes - if: needs.changes.outputs.wamr == 'true' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v5 - - - uses: pnpm/action-setup@v4 - - - uses: actions/setup-node@v5 - with: - node-version: 24 - cache: 'pnpm' - - - run: pnpm install --frozen-lockfile - - - name: Check for WAMR C sources - id: sources - run: | - if find packages/nativescript-wamr/src/vendors/wamr -name '*.c' -o -name '*.h' | grep -q .; then - echo "present=true" >> "$GITHUB_OUTPUT" - else - echo "present=false" >> "$GITHUB_OUTPUT" - echo "::warning::WAMR C sources not found — Gradle steps will be skipped." - fi - - - uses: actions/setup-java@v4.9.0 - if: steps.sources.outputs.present == 'true' - with: - distribution: temurin - java-version: '21' - - - uses: gradle/actions/setup-gradle@v4.4.4 - if: steps.sources.outputs.present == 'true' - with: - validate-wrappers: false - - - name: Install build tools (if missing) - if: steps.sources.outputs.present == 'true' - run: | - if ! command -v clang++ >/dev/null 2>&1; then - sudo apt-get update -qq && sudo apt-get install -y -qq clang - fi - - - name: JVM host tests (JavaCPP bindings + Kotlin wrapper) - if: steps.sources.outputs.present == 'true' - working-directory: packages/nativescript-wamr/platforms/android/wamr-android - run: ./gradlew :hosttest:test - - - uses: actions/upload-artifact@v4 - if: failure() && steps.sources.outputs.present == 'true' - with: - name: wamr-android-test-report - path: packages/nativescript-wamr/platforms/android/wamr-android/hosttest/build/reports/tests/test - - # The test app's mocha suite, on a simulator and an emulator. This is the only - # place the plugin's TypeScript adapters run against the real native layer — - # the NSData/NSArray marshalling on iOS, the signed byte[] handling on Android - # — so it covers what neither the Swift/Kotlin suites nor a Node runner can. - wasm-test-ios: + # iOS: compiles the vendored WAMR C sources through SwiftPM and runs the + # XCTest suite, which exercises the runtime natively on macOS. + wamr-ios: + needs: changes + if: needs.changes.outputs.wamr == 'true' + runs-on: macos-latest + steps: + - uses: actions/checkout@v5 + + - uses: actions/setup-node@v5 + with: + node-version: 24 + + - name: Check for WAMR C sources + id: sources + run: | + if find packages/nativescript-wamr/src/vendors/wamr -name '*.c' -o -name '*.h' | grep -q .; then + echo "present=true" >> "$GITHUB_OUTPUT" + else + echo "present=false" >> "$GITHUB_OUTPUT" + echo "::warning::WAMR C sources not found — Swift steps will be skipped." + fi + + - name: Verify the synced Swift package copy is current + if: steps.sources.outputs.present == 'true' + working-directory: packages/nativescript-wamr + run: | + node tools/sync-wamr.mjs + git diff --exit-code -- . || { + echo "::error::platforms/ios/NSCWamr/Sources/CWamr is stale — run 'pnpm run sync.vendors' and commit the result." + exit 1 + } + + - name: Swift tests + if: steps.sources.outputs.present == 'true' + working-directory: packages/nativescript-wamr/platforms/ios/NSCWamr + run: swift test --disable-sandbox + + - name: Build for iOS device + if: steps.sources.outputs.present == 'true' + working-directory: packages/nativescript-wamr/platforms/ios/NSCWamr + run: | + xcodebuild build \ + -scheme NSCWamr \ + -destination 'generic/platform=iOS' + + # Android: builds the wamr-jni Rust crate via cargo and runs the Kotlin + # wrapper's JUnit hosttest suite — no emulator. + wamr-android: + needs: changes + if: needs.changes.outputs.wamr == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v5 + with: + node-version: 24 + cache: 'pnpm' + + - run: pnpm install --frozen-lockfile + + - name: Check for WAMR C sources + id: sources + run: | + if find packages/nativescript-wamr/src/vendors/wamr -name '*.c' -o -name '*.h' | grep -q .; then + echo "present=true" >> "$GITHUB_OUTPUT" + else + echo "present=false" >> "$GITHUB_OUTPUT" + echo "::warning::WAMR C sources not found — Gradle steps will be skipped." + fi + + - uses: actions/setup-java@v4.9.0 + if: steps.sources.outputs.present == 'true' + with: + distribution: temurin + java-version: '21' + + - uses: gradle/actions/setup-gradle@v4.4.4 + if: steps.sources.outputs.present == 'true' + with: + validate-wrappers: false + + # Rust is pre-installed on ubuntu-latest; install if missing (e.g. act). + - name: Install Rust (if missing) + if: steps.sources.outputs.present == 'true' + run: | + if ! command -v cargo >/dev/null 2>&1; then + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal + echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" + fi + + - name: JVM host tests (cargo build + Kotlin wrapper) + if: steps.sources.outputs.present == 'true' + working-directory: packages/nativescript-wamr/platforms/android/wamr-android + run: ./gradlew :hosttest:test + + - uses: actions/upload-artifact@v4 + if: failure() && steps.sources.outputs.present == 'true' + with: + name: wamr-android-test-report + path: packages/nativescript-wamr/platforms/android/wamr-android/hosttest/build/reports/tests/test + + # The test app's mocha suite, on a simulator and an emulator. This is the only + # place the plugin's TypeScript adapters run against the real native layer — + # the NSData/NSArray marshalling on iOS, the signed byte[] handling on Android + # — so it covers what neither the Swift/Kotlin suites nor a Node runner can. + wasm-test-ios: needs: changes if: needs.changes.outputs.app == 'true' runs-on: macos-latest @@ -381,10 +380,10 @@ jobs: - run: pnpm install --frozen-lockfile - # The app resolves the plugin through a file: dependency and imports its - # built dist/, so the TypeScript has to exist before the app is bundled. - - name: Build the plugin - run: pnpm exec nx run nativescript-wasm3:build + # The app resolves both plugins through file: dependencies and imports + # their built dist/, so TypeScript must exist before bundling. + - name: Build the plugins + run: pnpm exec nx run-many -t build -p nativescript-wasm3 nativescript-wamr - name: Install the test app's dependencies working-directory: apps/nativescript-wasm-test @@ -394,10 +393,10 @@ jobs: # the CLI treats as a failed CocoaPods check unless LANG is a UTF-8 one. # The plugin itself needs no pods — it ships a Swift package. - name: Mocha specs on the iOS Simulator - working-directory: . - env: - LANG: en_US.UTF-8 - run: pnpm exec sh -c 'cd apps/nativescript-wasm-test && ns test ios --emulator' + working-directory: . + env: + LANG: en_US.UTF-8 + run: pnpm exec sh -c 'cd apps/nativescript-wasm-test && ns test ios --emulator' wasm-test-android: needs: changes @@ -420,7 +419,7 @@ jobs: - run: pnpm install --frozen-lockfile - name: Build the plugin - run: pnpm exec nx run nativescript-wasm3:build + run: pnpm exec nx run nativescript-wasm3:build - name: Install the test app's dependencies working-directory: apps/nativescript-wasm-test @@ -439,12 +438,12 @@ jobs: sudo udevadm control --reload-rules sudo udevadm trigger --name-match=kvm - # The plugin ships a prebuilt .aar with the wasm3 .so files for every ABI, - # so this needs no NDK — just the SDK the emulator action installs. + # The wasm3 plugin ships a prebuilt .aar with its .so files. + # WAMR tests will fail at runtime without it (known: continue-on-error). - name: Mocha specs on the Android emulator uses: reactivecircus/android-emulator-runner@v2 with: api-level: 34 arch: x86_64 - working-directory: . - script: pnpm exec sh -c 'cd apps/nativescript-wasm-test && ns test android --emulator' + working-directory: . + script: pnpm exec sh -c 'cd apps/nativescript-wasm-test && ns test android --emulator' diff --git a/.github/workflows/code-review.yml b/.github/workflows/code-review.yml index 4c7f476..60ccf7e 100644 --- a/.github/workflows/code-review.yml +++ b/.github/workflows/code-review.yml @@ -42,7 +42,7 @@ jobs: You are a senior code reviewer for a NativeScript monorepo built with Nx. The codebase spans: a TypeScript NativeScript plugin (packages/nativescript-wasm3) that bridges WebAssembly (wasm3) to - iOS (Swift) and Android (Kotlin + JavaCPP JNI); a Rust wasm-pack + iOS (Swift) and Android (Kotlin + Rust JNI); a Rust wasm-pack fixture crate (packages/nativescript-wasm-fixture); and a NativeScript test app (apps/nativescript-wasm-test) running mocha on device/emulator. diff --git a/.gitignore b/.gitignore index d8a7927..66cc9a2 100644 --- a/.gitignore +++ b/.gitignore @@ -60,10 +60,13 @@ vitest.config.*.timestamp* .reasonix/desktop-topic-created-at.json .reasonix/desktop-topic-title-sources.json .reasonix/desktop-topic-titles.json - -# DeepSeek CR local config (contains API token) -.deepseek-cr.config.json -reasonix.toml - -# pnpm local package store (created during resolution) -.pnpm-store + +# DeepSeek CR local config (contains API token) +.deepseek-cr.config.json + +reasonix.toml + +/packages/nativescript-wasm3/src/vendors/wasm3-rust/target/ + +# pnpm local package store (created during resolution) +.pnpm-store diff --git a/AGENTS.md b/AGENTS.md index 20b5ebb..bf7867c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,28 +37,402 @@ ## NativeScript plugins in this repo -Two sibling plugins live under `packages/`: +Two sibling plugins live under `packages/`. They are mirror images: same +class shapes, same wire protocol, same error mapping. Everything both +plugins share is documented in [Shared plugin architecture](#shared-plugin-architecture) +below; each package's AGENTS.md holds only engine-specific detail. -- **`nativescript-wasm3`** (`@org/nativescript-wasm3`) — mature plugin binding - the wasm3 interpreter (Swift Package on iOS, Kotlin + JavaCPP on Android). +- **`nativescript-wasm3`** (`@cross-code/nativescript-wasm3`) — mature plugin binding + the wasm3 interpreter (Swift Package on iOS, Kotlin + Rust JNI (cargo-ndk) on Android). See `packages/nativescript-wasm3/AGENTS.md`. -- **`nativescript-wamr`** (`@org/nativescript-wamr`) — newer plugin binding +- **`nativescript-wamr`** (`@cross-code/nativescript-wamr`) — newer plugin binding WAMR (WebAssembly Micro Runtime) with four execution tiers (Interpreter, - Fast JIT, LLVM JIT, AOT), WASI support, and the same wire protocol as wasm3. + Fast JIT, LLVM JIT, AOT) and WASI support in its API — the current native + builds enable only the interpreter (see [Key differences](#key-differences-wasm3-vs-wamr)). + WAMR-2.3.0 sources are vendored at `packages/nativescript-wamr/src/vendors/wamr/`. See `packages/nativescript-wamr/AGENTS.md`. -Both plugins share the same architecture and conventions: a platform-agnostic -wire protocol (`wire.ts`), per-platform adapter files, Swift @objc classes on -iOS, Kotlin + JavaCPP on Android, and Nx targets declared via `package.json`. +### Vitest + NativeScript unit-test packages -**Important (wamr)**: `packages/nativescript-wamr/src/vendors/wamr/` is -intentionally **empty** (only a README) until the WAMR C source tree is -populated. CI jobs for the native suites detect the absence and **skip -gracefully** (see `.github/workflows/ci.yml`, "Check for WAMR C sources" -steps) rather than fail. When working on wamr, do not rely on CI native -steps passing until sources are added. +- **`vitest-nativescript`** (`@cross-code/vitest-nativescript`) — a Vitest + custom pool that runs unit tests in NativeScript Worker runtimes. Read + `packages/vitest-nativescript/AGENTS.md` before changing its Node/device + protocol or webpack aliases. +- **`vitest-nativescript-ui`** (`@cross-code/vitest-nativescript-ui`) — an + optional NativeScript Core results view. It is presentation-only and should + remain removable for headless or CI usage. +- These packages support one-shot unit tests; they are not a component-testing + or end-to-end framework. Run their Nx `build`, `typecheck`, and `test` + targets with `pnpm exec nx`. + +**wamr native suites**: if the vendored WAMR C sources are ever absent, the +wamr native commands and CI jobs (`wamr-ios`, `wamr-android`) skip gracefully +rather than fail — the CI jobs emit a warning and skip the native steps (see +`.github/workflows/ci.yml`, "Check for WAMR C sources"), and the hosttest +`:test` task's `onlyIf` skips silently. Don't rely on CI native steps passing +until sources are present. + +## Shared plugin architecture + +Both plugins follow the same architecture: a platform-agnostic wire protocol +(`src/lib/wire.ts`), per-platform TypeScript adapter files, Swift `@objc` +classes on iOS, Kotlin + Rust JNI on Android, and Nx targets declared via +`package.json`. Substitute `` = `Wasm3`-family +(`Wasm3Runtime`, `Wasm3Module`, `Wasm3Function`, `Wasm3Error`) or +`Wamr`-family (`WamrRuntime`, `WamrModule`, `WamrFunction`, `WamrError`). + +### Wire protocol and value marshalling + +| WASM type | JS argument (in) | JS result (out) | +| --------- | --------------------------------------- | --------------- | +| `i32` | `number`, `string`, or `bigint` | `number` | +| `i64` | `bigint`, `string`, or `number` (small) | `bigint` | +| `f32` | `number` or `string` | `number` | +| `f64` | `number` or `string` | `number` | + +- `i64` crosses the native bridge as a **decimal string** for lossless + precision: Swift `String(Int64(bitPattern: slot))`, Kotlin `Long.toString()`, + TypeScript `BigInt(...)` on arrival and `.toString()` on send. `bigint` is + the canonical JS type; the adapters accept all three input forms. +- Multi-value returns come back as `WasmValue[]`; single-value as + `WasmValue`; void as `undefined`. +- **Android**: the NativeScript runtime boxes JS numbers as + `java.lang.Float` when `Object` is expected, silently truncating f64. The + adapters wrap every f32/f64 argument in `java.lang.Double.valueOf()` and + unbox returned `Number`s via `.doubleValue()` / `.toString()`. + +### Signature notation + +Both engines use the same notation: `"returns(params)"` — return types +**before** the parenthesized params. + +``` +"v()" void, no params +"i(ii)" i32 return, two i32 params +"I(II)" i64 return, two i64 params +"F(FF)" f64 return, two f64 params +"ii(i)" two i32 returns, one i32 param (multi-value) +"v(I)" void return, one i64 param +``` + +Letters: `i`=i32, `I`=i64, `f`=f32, `F`=f64, `v`=void. + +The TypeScript `parseSignature` in `src/lib/wire.ts` extracts group 1 (before +the paren) as **returns** and group 2 (inside the paren) as **params**. +Swapping those groups is a common mistake — verify the regex carefully. The +native layers convert this notation to the engine's own format internally +(WAMR's native format is `"(params)returns"`). + +### Missing imports surface at `findFunction` + +Both engines compile functions lazily. A missing imported function is +reported when `findFunction` is first called on a function that depends on an +unlinked import — **not** when the module is loaded and not at call time. +Tests and error handling must reflect this. + +### Module byte lifetime + +Both engines retain a **raw pointer** to the module bytes after parsing; the +bytes must outlive the module (and therefore the runtime). The native +wrappers keep the buffers alive — iOS: a buffer array on the runtime released +in `deinit`; Android: a `BytePointer` list released in `close()`. Don't let +callers think it's safe to free or overwrite the bytes after loading. + +### Host import lifetime + +Host import callbacks must outlive the runtime on **all three** levels: + +- **iOS**: a `[HostContext]` array on the runtime keeps the Swift closure + alive. The C trampoline receives an `Unmanaged` unretained pointer; the + array prevents deallocation. +- **Android**: a `mutableListOf<...>()` of raw-call trampolines on the + runtime prevents GC of the callback wrappers; released in `close()`. +- **JS**: the adapter retains the native callback objects (e.g. + `hostCallbacks[]`) for the runtime's lifetime. + +### iOS: NativeScript ObjC block-bridging bug + +The NativeScript iOS runtime has a known bug where a JS lambda passed as a +block parameter causes `EXC_BAD_ACCESS`. The workaround (used by both +plugins) is to subclass the `@objc` host-callback class via `.extend()` and +override its `invoke(_:)` method — the ObjC runtime dispatches through +`objc_msgSend`, bypassing block bridging. `invoke` must be declared +`@objc open dynamic` in Swift: without `dynamic`, Swift call sites go through +the vtable and reach the base implementation instead of the ObjC override. A +wrongly-named override key is silently accepted by `extend()` — the base +implementation then runs and returns `nil`, which the trampoline reports as a +trap on any import that returns a value. + +### One vendor source copy per engine + +SwiftPM requires sources to live inside the package directory. Rather than +maintaining two separate vendor copies, each plugin's `tools/sync-*.mjs` +treats `platforms/ios/NSCWasm3/Sources/CWasm3/` (or `NSCWamr/.../CWamr/`) as a +script-managed output: + +``` +npm run sync.vendors # copy src/vendors/ → iOS target +``` + +**Never edit the iOS `` copy directly.** Edit the canonical +`src/vendors//` and re-run the sync. The script also copies test +fixtures to the native test suites. For WAMR it also creates redirect headers +in `CWamr/include/` pointing at the four public headers (`wasm_export.h`, +`wasm_c_api.h`, `bh_platform.h`, `bh_read_file.h`). + +### Plugin-level SwiftPM declaration + +The NativeScript CLI 8.6+ merges a plugin's own `nativescript.config.ts` into +the consuming app. Each plugin declares: + +```ts +ios: { + SPMPackages: [ + { + name: 'NSCWasm3', + libs: ['NSCWasm3'], + path: `${__dirname}/platforms/ios/NSCWasm3`, + }, + ]; +} +``` + +**The path must be absolute.** There is no plugin-relative resolution: +`ios-project-service.js` collects a plugin's `SPMPackages` entries verbatim, +and `spm-service.js` then resolves each one against the _app_: + +```js +pkg.path = path.resolve(projectData.projectDir, pkg.path); +``` + +A relative `./platforms/ios/NSCWasm3` therefore points at +`/platforms/ios/NSCWasm3` — which is the CLI's own generated build folder, +so the failure is the confusing `the package at … cannot be accessed`. The CLI +loads this config with `Module.prototype._compile`, so `__dirname` is the +plugin's install directory, and `path.resolve` leaves an absolute path alone. + +After changing this, delete the app's generated `platforms/ios` — the bad path +is already written into `.pbxproj` and a rebuild alone will not correct it. + +### iOS Swift Package conventions + +**Sources**: `platforms/ios/NSCWasm3/` / `platforms/ios/NSCWamr/` + +- Native Swift/C interop (`import CWasm3` / `import CWamr`); no Objective-C + bridging header — engine C types are accessed directly. +- All public classes are annotated `@objc(ClassName)` so NativeScript can + instantiate them from JS without name-mangling: `NSCWasm3Runtime` / + `NSCWasm3Module` / `NSCWasm3Function`, and the `NSCWamr*` equivalents. +- `@objc` method selectors follow the NativeScript multi-label convention: + trailing `Error:` is stripped and the preceding label becomes an `error` + out-parameter — so `loadModule(_:error:)` is called from JS as + `runtime.loadModuleError(bytes)`. + +### Android: cargo-ndk + Rust JNI pipeline + +Both plugins use the identical Android architecture (no JavaCPP): + +- `platforms/android/-android/library/build.gradle.kts` registers a + `buildNative` task that runs `cargo ndk -t arm64-v8a -t armeabi-v7a -t x86 +-t x86_64 -o build/generated/native/jniLibs build -p -jni --release` + against the Rust workspace in `src/vendors/-rust/` (crates: + `-sys` = bindgen + compiles the engine C sources — the old C shim + is reimplemented in pure Rust there (`wasm3-sys/src/lib.rs`, + `wamr-sys/src/lib.rs` + `shim.rs`); `-ffi` = UniFFI; + `-jni` = the JNI layer). +- A Kotlin wrapper (`org.nativescript.wasm3.*` / `org.nativescript.wamr.*`, + `NSCWasm3.kt` + `NativeWasm3.kt`) loads `libwasm3_jni.so` / `libwamr_jni.so` + via JNI and `System.loadLibrary`. +- `deployAar` copies the release `.aar` to `platforms/android/nativescript-.aar`. + The `.aar` is **committed** because it contains precompiled `.so` files — + consumers don't need the NDK or a Rust toolchain. +- `hosttest/` is a pure-JVM module that compiles the Kotlin wrapper sources + and runs JUnit tests against a host (`cargo build --release -p -jni`) + build of the library, with `java.library.path` pointed at `target/release`. +- `include.gradle` declares no external dependencies — the JNI `.so` is + self-contained. + +### Android: Kotlin metadata version + +AGP 9's built-in Kotlin writes **2.4.x** metadata, but NativeScript's +metadata generator only understands **≤ 2.3.0** and silently skips every +class it cannot read — `globalThis.org.nativescript.` becomes +`undefined` and the plugin reports _"native runtime not found — is the plugin +installed and the app rebuilt?"_. Both libraries pin the metadata version in +`library/build.gradle.kts`: + +```kotlin +kotlin { + compilerOptions { + freeCompilerArgs.add("-Xmetadata-version=2.3.0") + } +} +``` + +If the classes ever become invisible again, read +`/platforms/android/build-tools/buildMetadata.log` first — it names every +skipped class and why. + +### Android: the Gradle project lives inside `platforms/android/` + +The NativeScript CLI scans a plugin's `platforms/android/` **recursively** for +`.aar`/`.jar` files and adds each as a Gradle dependency of the consuming app. +`-android/` sits in that directory, so after `npm run build.android` +its intermediates (`*/build/**`) are picked up too and the app fails to +configure: + +``` +A problem occurred configuring project ':app'. +Could not find :library-release:. +``` + +`package.json#files` keeps those out of the _published_ package, but apps that +consume the plugin through a `file:` dependency — like +`apps/nativescript-wasm-test` — see the whole working tree. Before building an +app against a locally built plugin: + +```bash +rm -rf platforms/android/-android/{,*/}build +``` + +Then delete the app's own `platforms/android`, since the bad dependency is +already written into its generated `build.gradle`. + +### hosttest: JUnit 6 (Jupiter) gotchas + +The `:hosttest` modules use **JUnit 6** (`org.junit.jupiter.api.Test` plus +`kotlin.test`, `useJUnitPlatform()`). Gotchas: + +- **`kotlin("test")` still pulls Jupiter 5.** `kotlin-test-junit5` declares + Jupiter 5.10.x; the `org.junit:junit-bom` (6.1.2) upgrades everything to + 6.x. Keep the BOM — without it the classpath silently mixes JUnit 5 and 6. + Verify alignment with: + `./gradlew :hosttest:dependencies --configuration testRuntimeClasspath | grep junit` +- **`junit-platform-launcher` must be an explicit `testRuntimeOnly`.** Gradle + no longer injects it. +- `@Test` methods must return `Unit` — an expression body returning a value + makes Jupiter reject the method. +- After any JUnit change, confirm tests actually _ran_ — a misconfigured + platform reports `BUILD SUCCESSFUL` while discovering zero tests. Check + `hosttest/build/test-results/test/*.xml` for the expected test count. + +### Gradle 9 / AGP 9 constraints + +These are coupled — don't bump one without checking the other: + +- **Gradle 9.6+ requires AGP 9.** Gradle 9.6.0 removed the internal + `org.gradle.api.problems.internal.InternalProblems` API that AGP 8.x used. + AGP 8.x builds fail at plugin-apply time. (AGP 8.x works up to Gradle 9.5.) +- **AGP 9 rejects `org.jetbrains.kotlin.android`.** Kotlin support is built in; + applying the plugin is a hard error. `:library` declares no Kotlin plugin — + only the pure-JVM `:hosttest` module does (`org.jetbrains.kotlin.jvm`). +- **`aarMetadata.minCompileSdk` is pinned to 1** in `library/build.gradle.kts`. + AGP 9 changed the default to the library's own `compileSdk` (35), which + would force every consuming NativeScript app to compileSdk 35. Nothing in + either library exposes API-35 surface, so the pre-AGP-9 contract is kept + explicitly. +- **`sourceSets { ... srcDirs(...) }` is deprecated in AGP 9** — use + `java.directories.addAll(...)` / `jniLibs.directories.add(...)`. +- **Avoid `val x by tasks.registering(T::class)`** — the Kotlin DSL delegate + is deprecated and breaks in Gradle 10. Use `tasks.register("name") { }`. + +### Environment requirements + +| Tool | Version | Notes | +| --------------------- | ------------- | ------------------------------------------------------------------ | +| Node | 22.13+ | pnpm 11.20.0 (see `packageManager`); asdf pins 24.18.1, CI uses 24 | +| pnpm | 11.20.0 | default package manager | +| Swift | 6.3+ | macOS; for iOS build/test | +| Xcode | 16+ | iOS device build | +| JDK | 17–21 | Android build; 21 (temurin) is used by asdf/CI | +| Android NDK | 29.0.14206865 | set via `ANDROID_HOME`; required by `cargo ndk` | +| Rust | stable | with `cargo-ndk` — Android cross-compile + hosttest | +| Gradle wrapper | 9.6.1 | auto-downloaded by wrapper | +| Android Gradle Plugin | 9.3.1 | required by Gradle 9.6 (see above) | +| Kotlin | 2.4.x | AGP 9 built-in; `-Xmetadata-version=2.3.0` for NS metadata | + +No globally installed gradle, cocoapods, or wasm toolchain is required. + +### `ns typings` before native-API TypeScript + +Before writing TypeScript that calls native APIs, generate declarations for +the `@objc` Swift / Kotlin classes. `ns typings` must be run **from the test +app directory** (`apps/nativescript-wasm-test`), not the plugin directory — it +needs a fully prepared NativeScript project with platform directories: + +```bash +cd apps/nativescript-wasm-test +npx ns typings ios +npx ns typings android +``` + +The generated `.d.ts` files land in `apps/nativescript-wasm-test/typings/`. +Copy the relevant native class declarations back to the plugin — they are the +source-of-truth for native API types consumed by the TypeScript adapters. + +**Sandboxed macOS terminals (EPERM on `~/.local/share/.nativescript-cli/`):** +the `ns` CLI writes state to that directory on startup; if your terminal/IDE +is sandboxed it is immutable. Patch the CLI to use `/tmp` (must re-run after +`npm install`): + +```bash +sed -i '' 's|path.join(defaultProfileDirLocation, this.$staticConfig.PROFILE_DIR_NAME)|"/tmp/.nativescript-cli"|' \ + node_modules/nativescript/lib/common/services/settings-service.js +``` + +The `--profile-dir` flag exists but is applied too late in startup to avoid +the sandbox hit. + +## Testing the plugins + +Three test layers, each covering a different slice: + +- **vitest unit specs** (per plugin) — the TypeScript adapters against mocked + native globals (`globalThis.NSCWasm3Runtime`, `org.nativescript.wamr.*`, + …). No native toolchain, runs in CI. +- **native suites** (per plugin) — iOS XCTests (`npm run test.ios`, runs the + engine natively on macOS) and Android JVM host tests (`npm run test.android`, + via the `:hosttest` Gradle module). +- **the test app** (`apps/nativescript-wasm-test`) — the only place the + TypeScript adapters meet the real native layer on a device: `NSData` / + `NSArray` unwrapping on iOS, signed Java `byte[]` handling on Android, + i64-as-decimal-string on both. Run its suite on **both** platforms when you + touch `wire.ts` or an adapter file. Details in + `apps/nativescript-wasm-test/AGENTS.md`. + +The shared check suite lives in the fixture package +(`@cross-code/nativescript-wasm-fixture`, Rust + wasm-pack): the test app's +`app/wasm/fixture-suite.ts` is the canonical correctness specification, typed +against structural interfaces (`WasmModuleLike` / `WasmRuntimeLike`) rather +than either plugin, and `callFixture` is type-checked against the +wasm-pack-generated `.d.ts`. See `packages/nativescript-wasm-fixture/README.md`. + +## Key differences: wasm3 vs WAMR + +| Aspect | wasm3 | WAMR | +| ---------------------------- | ------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------- | +| **Runtime model** | Single-call interpreter | Interpreter + JIT + AOT tiers | +| **Module lifecycle** | Parse → immediate module | Two-phase: load (parse+compile) → instantiate | +| **Execution env** | Implicit (per-call stack) | Explicit `wasm_exec_env_t`, created once per runtime | +| **WASI support** | None | Engine supports it; plugin's native builds currently compile it out (`WASM_ENABLE_LIBC_WASI 0` on both platforms) | +| **Host imports** | Per-import `M3RawCall` trampoline | Universal trampoline + context matching (iOS) or per-import `WasmRawCall` (Android) | +| **Stack ABI** | M3-style: results first, then args | WAMR raw convention: same layout | +| **Global access** | `M3TaggedValue` union (iOS direct, Android via shim) | `wasm_global_t` struct (native API, no shim needed) | +| **Memory access** | Direct pointer arithmetic | `wasm_runtime_addr_app_to_native` translation | +| **C API style** | Flat `m3_*` functions | Namespaced `wasm_runtime_*` functions | +| **Android JNI** | cargo-ndk Rust (`wasm3-jni`), globals via pure-Rust `nsc_global_get/set` | cargo-ndk Rust (`wamr-jni`), `nsc_wamr_*` shim surface reimplemented in Rust | +| **Missing import detection** | At `findFunction` (lazy compile) | At `findFunction` (lazy compile, same) | + +## Per-package guidance + +| Path | Contents | +| --------------------------------------- | ----------------------------------------------------------------------- | +| `packages/nativescript-wasm3/AGENTS.md` | wasm3-specific: stack ABI, globals, fixtures, build/test | +| `packages/nativescript-wamr/AGENTS.md` | WAMR-specific: two-phase load, exec env, WASI, tiers, trampolines, shim | +| `apps/nativescript-wasm-test/AGENTS.md` | test app: layout, design decisions, running the suites, adding specs | + ## MCP Tools: code-review-graph **IMPORTANT: This project has a knowledge graph. ALWAYS use the @@ -79,16 +453,16 @@ Fall back to Grep/Glob/Read **only** when the graph doesn't cover what you need. ### Key Tools -| Tool | Use when | -| ------ | ---------- | -| `detect_changes_tool` | Reviewing code changes — gives risk-scored analysis | -| `get_review_context_tool` | Need source snippets for review — token-efficient | -| `get_impact_radius_tool` | Understanding blast radius of a change | -| `get_affected_flows_tool` | Finding which execution paths are impacted | -| `query_graph_tool` | Tracing callers, callees, imports, tests, dependencies | -| `semantic_search_nodes_tool` | Finding functions/classes by name or keyword | -| `get_architecture_overview_tool` | Understanding high-level codebase structure | -| `refactor_tool` | Planning renames, finding dead code | +| Tool | Use when | +| -------------------------------- | ------------------------------------------------------ | +| `detect_changes_tool` | Reviewing code changes — gives risk-scored analysis | +| `get_review_context_tool` | Need source snippets for review — token-efficient | +| `get_impact_radius_tool` | Understanding blast radius of a change | +| `get_affected_flows_tool` | Finding which execution paths are impacted | +| `query_graph_tool` | Tracing callers, callees, imports, tests, dependencies | +| `semantic_search_nodes_tool` | Finding functions/classes by name or keyword | +| `get_architecture_overview_tool` | Understanding high-level codebase structure | +| `refactor_tool` | Planning renames, finding dead code | ### Workflow diff --git a/README.md b/README.md index 2fb8b49..2ae9545 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,11 @@ # cross-code An Nx monorepo for running WebAssembly on [NativeScript](https://nativescript.org) — -two sibling plugins: [`wasm3`](https://github.com/wasm3/wasm3) (interpreted) and -[WAMR](https://github.com/bytecodealliance/wasm-micro-runtime) (interpreted/JIT/AOT). +two sibling plugins with a shared TypeScript API: + +- [`wasm3`](https://github.com/wasm3/wasm3) — lightweight interpreter (v0.5.2) +- [WAMR](https://github.com/bytecodealliance/wasm-micro-runtime) — WebAssembly + Micro Runtime (2.3.0): interpreter, Fast JIT, LLVM JIT, AOT, WASI > **Project status: Active development.** APIs and project layout may change without notice; expect breaking changes between releases. @@ -10,11 +13,15 @@ two sibling plugins: [`wasm3`](https://github.com/wasm3/wasm3) (interpreted) and | Package | Description | |---------|-------------| -| [`@cross-code/nativescript-wasm3`](packages/nativescript-wasm3) | NativeScript plugin — Swift Package on iOS, Kotlin + JavaCPP JNI on Android (wasm3 interpreter) | -| [`@cross-code/nativescript-wamr`](packages/nativescript-wamr) | NativeScript plugin — Swift Package on iOS, Kotlin + JavaCPP JNI on Android (WAMR: interpreter, Fast JIT, LLVM JIT, AOT, WASI) | +| [`@cross-code/nativescript-wasm3`](packages/nativescript-wasm3) | NativeScript plugin — Swift Package on iOS, Kotlin + Rust JNI (cargo-ndk) on Android (wasm3 interpreter) | +| [`@cross-code/nativescript-wamr`](packages/nativescript-wamr) | NativeScript plugin — Swift Package on iOS, Kotlin + Rust JNI (cargo-ndk) on Android (WAMR: interpreter, Fast JIT, LLVM JIT, AOT, WASI) | | [`@cross-code/nativescript-wasm-fixture`](packages/nativescript-wasm-fixture) | Rust/wasm-pack test fixtures (committed `.wasm` binaries) | | [`nativescript-wasm-test`](apps/nativescript-wasm-test) | NativeScript test app — runs the plugins on a simulator/emulator from a demo page and under mocha | +Both plugins expose the same TypeScript API — see [Using the plugins](#using-the-plugins). +Each package README covers its own layout, development workflow and troubleshooting +(see [Per-package documentation](#per-package-documentation)). + ## Prerequisites - Node 22.13+, pnpm (the default package manager — `packageManager` in `package.json`) @@ -63,11 +70,11 @@ pnpm exec nx run nativescript-wasm-test:test.android On macOS, `ns test ios` needs a UTF-8 locale (`export LANG=en_US.UTF-8`) — otherwise the CLI's CocoaPods check fails before the build starts. -> **wamr native suites**: `packages/nativescript-wamr/src/vendors/wamr/` is -> intentionally empty (only a README) until the WAMR C source tree is -> populated. Until then, the wamr native commands and CI jobs (`wamr-ios`, -> `wamr-android`) **skip gracefully** — they print a `SKIP:` message and exit -> 0 rather than fail. The TypeScript layer and vitest specs run normally. +> **wamr native suites** need the vendored WAMR C sources +> (`packages/nativescript-wamr/src/vendors/wamr/`, WAMR-2.3.0). If the source +> tree is ever missing, the wamr native commands and CI jobs (`wamr-ios`, +> `wamr-android`) skip gracefully — they print a `SKIP:` message and exit 0 +> rather than fail. The TypeScript layer and vitest specs run normally. ## Nx tasks @@ -83,4 +90,217 @@ pnpm exec nx affected -t build test pnpm exec nx graph ``` -See each package's README for platform-specific build and troubleshooting details. +## Using the plugins + +Both plugins share one API — only the class names differ +(`Wasm3Runtime` / `Wasm3Module` / `Wasm3Function` vs +`WamrRuntime` / `WamrModule` / `WamrFunction`), and errors from native code are +thrown as `Wasm3Error` / `WamrError` respectively. Examples below use wasm3; +substitute the class names for WAMR. + +| | `@cross-code/nativescript-wasm3` | `@cross-code/nativescript-wamr` | +|---|---|---| +| Engine | wasm3 interpreter (v0.5.2) | WAMR 2.3.0 | +| Execution | interpreter only | `Interpreter` (default), `FastJIT`, `LLVMJIT`, `AOT` | +| WASI | — | opt-out via `wasiEnabled` (default `true`) | +| Runtime options | `stackSizeInBytes` | `stackSizeInBytes`, `wasiEnabled`, `executionTier` | + +### Install + +```bash +ns plugin add @cross-code/nativescript-wasm3 +# or: ns plugin add @cross-code/nativescript-wamr +``` + +Each plugin ships its own `nativescript.config.ts` declaring the local Swift +package (`ios.SPMPackages`), which NativeScript CLI 8.6+ merges into your +app — no Podfile and no app-side configuration needed. On Android the +bundled `.aar` and `include.gradle` are picked up automatically. + +### Quick start + +```ts +import { knownFolders, path } from '@nativescript/core'; +import { Wasm3Runtime } from '@cross-code/nativescript-wasm3'; + +const runtime = new Wasm3Runtime(); // default 64 KiB stack +// const runtime = new Wasm3Runtime({ stackSizeInBytes: 128 * 1024 }); + +// Load from a file path +const wasmPath = path.join(knownFolders.currentApp().path, 'assets/module.wasm'); +const module = runtime.loadModule(wasmPath); + +// …or from bytes (ArrayBuffer, Uint8Array, or number[]) +const module2 = runtime.loadModule(wasmBytes); + +runtime.dispose(); // releases native resources; safe to call multiple times +``` + +### Calling exports + +```ts +// find + call in one step (result is unwrapped automatically) +runtime.call('add_i32', 2, 40); // 42 +runtime.call('add_i64', 2n ** 62n, 1n); // 4611686018427387905n (bigint) +runtime.call('div_f64', 1, 8); // 0.125 +runtime.call('swap', 1, 2); // [2, 1] (multi-value return) + +// inspect before calling +const fn = runtime.findFunction('add_i64'); +fn.name; // 'add_i64' +fn.paramTypes; // ['i64', 'i64'] +fn.returnTypes; // ['i64'] +fn.call(1n, 2n); // 3n + +// same from a module handle +module.call('add_i32', 1, 2); +module.findFunction('add_i32').call(1, 2); +``` + +### Linear memory + +```ts +runtime.writeMemory(16, [0xde, 0xad, 0xbe, 0xef]); +runtime.readMemory(16, 4); // Uint8Array [0xde, 0xad, 0xbe, 0xef] +runtime.memorySize; // e.g. 65536 +``` + +### Globals + +```ts +module.getGlobal('g_counter'); // number or bigint (i64 → bigint) +module.setGlobal('g_counter', 100); // accepts number, bigint, or string +module.getGlobal('g_big'); // bigint +module.setGlobal('g_big', 2n ** 63n); +``` + +### Host imports — WASM calling back into JavaScript + +Link JavaScript functions as WebAssembly imports before the first call into +the module. Signatures use wasm3/WAMR notation: return type(s) **before** the +parenthesized params. + +Signature letters: `i`=i32 `I`=i64 `f`=f32 `F`=f64 `v`=void + +```ts +// inline at load time +const module = runtime.loadModule(wasmPath, { + env: { + host_add: { signature: 'i(ii)', fn: (a, b) => Number(a) + Number(b) }, + host_log_i64: { signature: 'v(I)', fn: (v) => console.log('i64:', v) }, // bigint arg + host_pi: { signature: 'F()', fn: () => Math.PI }, + }, +}); + +// or individually +module.linkHostFunction('env', 'host_add', 'i(ii)', (a, b) => Number(a) + Number(b)); +``` + +Host functions receive arguments as the natural JS types (`number` for i32/f32/f64, +`bigint` for i64) and must return the same. Multi-value returns use an array. + +Imports must be linked before the first function call that depends on them. +The engines report missing imports when `findFunction` is first called (lazy +compile), not when the module is loaded. + +### Value marshalling + +| WASM type | JS argument (in) | JS result (out) | +|-----------|-----------------|-----------------| +| `i32` | `number`, `string`, or `bigint` | `number` | +| `i64` | `bigint`, `string`, or `number` (small) | `bigint` | +| `f32` | `number` or `string` | `number` | +| `f64` | `number` or `string` | `number` | + +`i64` crosses the native bridge as decimal strings for lossless precision. +Multi-value returns come back as `WasmValue[]`; single-value as `WasmValue`; +void as `undefined`. + +### Errors + +All errors from native code are thrown as `Wasm3Error` / `WamrError` +(subclasses of `Error` with `name === 'Wasm3Error'` / `'WamrError'`). Common +messages: + +| Message | Cause | +|---------|-------| +| `missing imported function` | `findFunction` called before all imports are linked | +| `function not found` | export name does not exist | +| `memory read/write out of bounds` | offset + length exceeds `memorySize` | +| `module has no linear memory` | WASM module didn't declare a memory section | +| `global not found` | no exported global with that name | +| `expected N arguments, got M` | wrong arity | + +### API reference + +#### `new Wasm3Runtime(options?)` / `new WamrRuntime(options?)` + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `stackSizeInBytes` | `number` | `65536` | interpreter stack size | +| `wasiEnabled` *(wamr only)* | `boolean` | `true` | enable WASI support for the module | +| `executionTier` *(wamr only)* | `WamrExecutionTier` | `Interpreter` | execution engine — see the [wamr README](packages/nativescript-wamr/README.md#execution-tiers) | + +**Static** + +| Method | Returns | Description | +|--------|---------|-------------| +| `Wasm3Runtime.version()` / `WamrRuntime.version()` | `string` | engine version, e.g. `"0.5.2"` (wasm3) / `"2.3.0"` (WAMR) | + +**Instance** + +| Method / property | Returns | Description | +|-------------------|---------|-------------| +| `loadModule(source, imports?)` | `Wasm3Module` / `WamrModule` | Load from file path, `ArrayBuffer`, `Uint8Array`, or `number[]` | +| `findFunction(name)` | `Wasm3Function` / `WamrFunction` | Find an export across all loaded modules | +| `call(name, ...args)` | `WasmValue \| WasmValue[] \| undefined` | Find + call in one step | +| `memorySize` | `number` | Linear memory size in bytes | +| `readMemory(offset, length)` | `Uint8Array` | Read raw bytes | +| `writeMemory(offset, bytes)` | `void` | Write raw bytes | +| `dispose()` | `void` | Release native resources; safe to call multiple times | + +#### `Wasm3Module` / `WamrModule` + +| Method / property | Returns | Description | +|-------------------|---------|-------------| +| `name` | `string` | Module name from the WASM binary | +| `runtime` | `Wasm3Runtime` / `WamrRuntime` | The runtime this module belongs to | +| `findFunction(name)` | `Wasm3Function` / `WamrFunction` | Delegates to `runtime.findFunction` | +| `call(name, ...args)` | `WasmValue \| WasmValue[] \| undefined` | Delegates to `runtime.call` | +| `linkHostFunction(module, name, signature, fn)` | `void` | Link one JS host function | +| `linkImports(imports)` | `void` | Link a nested `{module:{name:{signature,fn}}}` object | +| `getGlobal(name)` | `WasmValue` | Read an exported global (i64 → bigint) | +| `setGlobal(name, value)` | `void` | Write a mutable exported global | + +#### `Wasm3Function` / `WamrFunction` + +| Property / method | Type / Returns | Description | +|-------------------|----------------|-------------| +| `name` | `string` | Export name | +| `paramTypes` | `WasmValueType[]` | e.g. `['i32', 'i64']` | +| `returnTypes` | `WasmValueType[]` | e.g. `['i32']`; multi-value supported | +| `call(...args)` | `WasmValue \| WasmValue[] \| undefined` | Invoke the function | + +### Troubleshooting + +**`nativescript-wasm3 native runtime not found` / `nativescript-wamr native runtime not found`** — the app wasn't rebuilt after adding the plugin. Run `ns build ios` or `ns build android`. + +**`missing imported function`** — a host import wasn't linked before +`findFunction`/`call` was used. Link all imports via `loadModule(src, imports)` +or `module.linkImports({...})` before the first call. + +**i64 values come back as `0n`** — i64 is bridged as a decimal string. Ensure +the TypeScript layer wraps the value with `BigInt(...)`. If writing custom +native code, return a string, not a number. + +Engine-specific build issues (unsynced C sources, stale `.aar`s) are in each +package's README. + +## Per-package documentation + +| Package | Docs | +|---------|------| +| `@cross-code/nativescript-wasm3` | [README](packages/nativescript-wasm3/README.md) — platform details, package layout, developing, troubleshooting, license | +| `@cross-code/nativescript-wamr` | [README](packages/nativescript-wamr/README.md) — execution tiers, package layout, developing, troubleshooting, license | +| `@cross-code/nativescript-wasm-fixture` | [README](packages/nativescript-wasm-fixture/README.md) — exported subpaths, rebuilding the `.wasm` fixtures | +| `nativescript-wasm-test` | [README](apps/nativescript-wasm-test/README.md) — running the demo page and the mocha suite, troubleshooting | diff --git a/apps/nativescript-wasm-test/AGENTS.md b/apps/nativescript-wasm-test/AGENTS.md index c298311..e51be5e 100644 --- a/apps/nativescript-wasm-test/AGENTS.md +++ b/apps/nativescript-wasm-test/AGENTS.md @@ -2,24 +2,34 @@ AI-agent guidance for working on the `nativescript-wasm-test` app. +Workspace-wide conventions — Nx usage, NativeScript docs, the `npx ns` rule, +environment requirements — live in the top-level [AGENTS.md](../../AGENTS.md). + --- ## What this app is -A NativeScript app that exercises `@cross-code/nativescript-wasm3` against the real -wasm3 interpreter, two ways: +A NativeScript app that exercises **both** WebAssembly plugins — +`@cross-code/nativescript-wasm3` and `@cross-code/nativescript-wamr` — against their real +native runtimes, two ways: + +- **A demo page** (`app/main-view-model.ts`) that runs the shared check suite on + each runtime and renders the results — useful on a physical device. +- **A mocha suite** (`app/tests/wasm3/` and `app/tests/wamr/`) run by + `ns test ios` / `ns test android` on a simulator or emulator. -- **A demo page** (`app/main-view-model.ts`) that runs the shared check suite and - renders the results — useful on a physical device. -- **A mocha suite** (`app/tests/`) run by `ns test ios` / `ns test android` on a - simulator or emulator. +Both go through each plugin's public TypeScript API onto the device's own wasm3 +and WAMR builds. That makes this the only place the plugins' *platform adapters* +are covered end to end — `NSData`/`NSArray` marshalling on iOS, signed Java +`byte[]` handling on Android, i64-as-decimal-string on both. The plugins' own +vitest specs stub the native layer out; their Swift and Kotlin suites test the +native side without the TypeScript. Only this app joins the two halves. -Both go through the plugin's public TypeScript API onto the device's own wasm3 -build. That makes this the only place the plugin's *platform adapters* are -covered end to end — `NSData`/`NSArray` marshalling on iOS, signed Java `byte[]` -handling on Android, i64-as-decimal-string on both. The plugin's own vitest specs -stub the native layer out; its Swift and Kotlin suites test the native side -without the TypeScript. Only this app joins the two halves. +The two plugins are deliberate mirror images of each other — same class shapes, +same wire protocol, same error mapping — which is what lets one check suite drive +both. WAMR adds what wasm3 has no equivalent for: four **execution tiers** +(Interpreter, Fast JIT, LLVM JIT, AOT) and optional **WASI** support, selected +per-runtime through `WamrRuntimeOptions` and exercised by the WAMR specs. > **TypeScript platform types** (`references.d.ts`): composed with > https://types.nativescript.org/agents — the iOS default `common.d.ts` bundle @@ -36,6 +46,10 @@ There is no off-device test run. An earlier version of this app ran the same checks on Node's `WebAssembly` engine through a fake of the Android native surface; that was removed when the suite moved to mocha-on-device. +The WAMR specs previously lived in a parallel app, `nativescript-wamr-test`, +which was a near-verbatim copy of this one. It was folded in here so the two +plugins share one app, one check suite and one CI job pair. + --- ## Layout @@ -44,12 +58,16 @@ surface; that was removed when the suite moved to mocha-on-device. app/ test.ts unit-test entry point; require.context pulls in **/*.spec.ts tests/ - fixture-module.spec.ts the Rust fixture through the plugin's whole API - globals-module.spec.ts mutable exported globals (getGlobal / setGlobal) + wasm3/ the same two specs, against @cross-code/nativescript-wasm3 + fixture-module.spec.ts the Rust fixture through the plugin's whole API + globals-module.spec.ts mutable exported globals (getGlobal / setGlobal) + wamr/ the same two, against @cross-code/nativescript-wamr, + fixture-module.spec.ts plus execution-tier and WASI coverage + globals-module.spec.ts wasm/ fixture-suite.ts shared check suite: runFixtureChecks, runGlobalsChecks wasm-assets.ts bundled .wasm paths + a platform-aware byte reader - main-view-model.ts demo UI — runs fixture-suite and displays results + main-view-model.ts demo UI — runs fixture-suite on both runtimes main-page.{ts,xml} NativeScript page glue karma.conf.js mocha + chai frameworks, NS launchers @@ -98,28 +116,37 @@ This mirrors what the runner's webpack config does: outside `env.unitTesting` it installs an `IgnorePlugin` that drops `test.ts` and `*.spec.ts` from the bundle. Both configs are type-checked by the `typecheck` target. -### 4. The specs run against real wasm3, so error text comes from wasm3 +### 4. The specs run against the real runtimes, so error text comes from them -Assertions on failure messages match wasm3's own strings — `function lookup -failed`, `missing imported function` (`src/vendors/wasm3/wasm3.h`) — with the -offending name appended by wasm3 as a quoted detail. Do not assert on the exact +Assertions on failure messages match each runtime's own strings — `function +lookup failed`, `missing imported function` (`src/vendors/wasm3/wasm3.h`) — with +the offending name appended as a quoted detail. Do not assert on the exact quoting; match the constant and the name separately. -Messages the plugin owns are stable and can be matched exactly: `global not -found: `, and the `context: ` prefix that `rethrow` adds in `wasm3.ts`. +Messages a plugin owns are stable and can be matched exactly: `global not +found: `, and the `context: ` prefix that `rethrow` adds in `wasm3.ts` / +`wamr.ts`. -wasm3 compiles lazily, so a missing import surfaces at `findFunction` — not at -`loadModule` and not at call time. `fixture-module.spec.ts` asserts exactly that. +The two runtimes differ on *when* a missing import surfaces: wasm3 compiles +lazily, so it appears at `findFunction` — not at `loadModule` and not at call +time. WAMR resolves imports eagerly on load. Each app's `fixture-module.spec.ts` +asserts its own runtime's behaviour; do not unify these two cases. ### 5. `fixture-suite.ts` is the canonical correctness specification -`app/wasm/fixture-suite.ts` holds the check list that both the demo page and the -specs run. When you add an export to `@cross-code/nativescript-wasm-fixture`, add the -check there — both pick it up. Do not add plugin-level marshalling checks -directly in the spec files; the specs delegate to +`app/wasm/fixture-suite.ts` holds the check list that the demo page and all four +spec files run. When you add an export to `@cross-code/nativescript-wasm-fixture`, add +the check there — every caller picks it up, on both runtimes. Do not add +plugin-level marshalling checks directly in the spec files; the specs delegate to `summarize(runFixtureChecks(...))` and only add cases that need their own assertions. +The suite imports **neither** plugin. It is typed against the structural +interfaces `WasmModuleLike` / `WasmRuntimeLike` / `HostImports` declared at the +top of the file, which `Wasm3Module` and `WamrModule` both satisfy. Keep it that +way: importing a type from one plugin would quietly make that plugin a +dependency of the other's specs. + ### 6. `callFixture` is type-checked against the wasm-pack `.d.ts` ```ts @@ -148,24 +175,31 @@ cover the `loadModule(bytes)` native entry point, `wasm-assets.ts` has `byte[]` on Android, so the conversion to `Uint8Array` branches on `globalThis.interop`. -### 9. Workspace wiring +### 9. WAMR execution tiers are opt-in at native build time + +`WamrExecutionTier` has four values — `Interpreter` (0), `FastJIT` (1), +`LLVMJIT` (2), `AOT` (3). Only `Interpreter` is guaranteed: the others need the +matching native build flags (and AOT needs pre-compiled `.aot` files). The specs +therefore construct the runtime inside a `try` and treat a construction failure +as a skip, not a failure. The demo page always uses `Interpreter`. + +### 10. Workspace wiring This app is **not** an npm workspace member (root `workspaces` is `packages/*`), so the NativeScript CLI keeps its own `node_modules`; the plugin and fixture are `file:` dependencies. Consequence: Nx does not read an `nx` block from this `package.json` — targets must live in `project.json`. -The plugin is consumed as built `dist/`, so `nx run nativescript-wasm3:build` -must have run before the app is bundled. Both `ns test` targets declare -`dependsOn: ["^build"]` for that reason. +Both plugins are consumed as built `dist/`, so `nx run nativescript-wasm3:build` +and `nx run nativescript-wamr:build` must have run before the app is bundled. +Both `ns test` targets declare `dependsOn: ["^build"]` for that reason. --- ## Running the tests -The `ns` CLI is a local devDependency — always invoke it with `npx ns` so it -resolves to `node_modules/.bin/ns` and never hits macOS permission issues on -`~/.local/share/.nativescript-cli`. +The `ns` CLI is a local devDependency — always invoke it with `npx ns` (see +the top-level [AGENTS.md](../../AGENTS.md#nativescript) for why). From the workspace root: @@ -207,6 +241,7 @@ npx ns test ios --device 73F3C71E-982C-4C2A-9AE3-CE75BC8FA2A2 "Cannot find a compatible Android SDK for compilation". - **Clean the plugin's Gradle intermediates before an Android build** — `rm -rf ../../packages/nativescript-wasm3/platforms/android/wasm3-android/{,*/}build` + (and the same under `nativescript-wamr/platforms/android/wamr-android/`) — otherwise the CLI picks them up as app dependencies and Gradle fails with `Could not find :library-release:`. Delete this app's `platforms/android` afterwards, since the bad dependency is baked into its generated @@ -216,13 +251,32 @@ npx ns test ios --device 73F3C71E-982C-4C2A-9AE3-CE75BC8FA2A2 that the device is not port-forwarded to, and the device then fails to fetch `context.json`. -### Known-failing: the plugin is not visible to JS on Android +### Fixed: the plugin was not visible to JS on Android + +`new Wasm3Runtime()` used to throw *"native runtime not found"* on Android: +the plugin's Kotlin classes were compiled with Kotlin 2.4.x, and NativeScript's +metadata generator only understands metadata ≤ 2.3.0, so it skipped all of +them. Both libraries now pin `-Xmetadata-version=2.3.0` (see the top-level +[AGENTS.md](../../AGENTS.md#android-kotlin-metadata-version)). If the error +reappears, check `/platforms/android/build-tools/buildMetadata.log` for +`Skip org.nativescript.wasm3.*`. + +### Fixed: the WAMR plugin was not consumable by the CLI + +Every spec under `app/tests/wamr/` used to fail on both platforms because of +issues in the plugin, not in this app: + +1. `packages/nativescript-wamr/package.json` lacked the `"nativescript"` + field, so the CLI never recognised it as a plugin, never read its + `nativescript.config.ts`, and never added `NSCWamr` to the generated Xcode + project. +2. The WAMR C sources were not vendored (only the shim was committed) and no + pre-built `.aar` existed for Android, unlike wasm3. -`new Wasm3Runtime()` currently throws *"native runtime not found"* on Android. -The plugin's Kotlin classes are compiled with Kotlin 2.4.x, and NativeScript's -metadata generator only understands metadata ≤ 2.3.0, so it skips all of them — -see `packages/nativescript-wasm3/AGENTS.md`, "Kotlin metadata version gates JS -visibility". The specs themselves are fine; they fail in `beforeEach`. +All four blockers are now fixed: the `"nativescript"` field + `Package.swift` +exclude (commit 812d7da), the vendored WAMR-2.3.0 sources, and the committed +`nativescript-wamr.aar`. If a WAMR spec fails now, treat it as a real +regression in the plugin or this app — not the old known state. --- @@ -232,6 +286,8 @@ visibility". The specs themselves are fine; they fail in `beforeEach`. `fixture-suite.ts:runFixtureChecks` — the demo page and the specs both pick it up with no further change. 2. Only write a new `it(...)` when the case needs its own assertions (a specific - error, a type, a native entry point the suite does not reach). + error, a type, a native entry point, or a WAMR-specific feature such as an + execution tier the suite does not reach). A change to shared marshalling + behaviour belongs in `fixture-suite.ts`, where both runtimes run it. 3. `pnpm exec nx run nativescript-wasm-test:typecheck`, then run the suite on both platforms — the two adapters are different code and fail differently. diff --git a/apps/nativescript-wasm-test/README.md b/apps/nativescript-wasm-test/README.md index 688cddb..4715af1 100644 --- a/apps/nativescript-wasm-test/README.md +++ b/apps/nativescript-wasm-test/README.md @@ -1,21 +1,25 @@ # nativescript-wasm-test -The test app for [`@cross-code/nativescript-wasm3`](../../packages/nativescript-wasm3). -It runs the WebAssembly fixture from +The test app for [`@cross-code/nativescript-wasm3`](../../packages/nativescript-wasm3) +and [`@cross-code/nativescript-wamr`](../../packages/nativescript-wamr). It runs the +WebAssembly fixture from [`@cross-code/nativescript-wasm-fixture`](../../packages/nativescript-wasm-fixture) -two ways, both on the device's own wasm3 interpreter: +two ways, on both of the device's own runtimes — the wasm3 interpreter and WAMR: - **from the demo page** — tap **RUN** for a per-check pass/fail report; - **under mocha** — `ns test ios` / `ns test android`, on a simulator or emulator. -Both execute the *same* checks, from `app/wasm/fixture-suite.ts`. +Both execute the *same* checks, from `app/wasm/fixture-suite.ts` — which is +typed against structural interfaces rather than either plugin, so one suite +drives both runtimes. ``` app/wasm/fixture-suite.ts the checks, shared by the demo page and the specs app/wasm/wasm-assets.ts where webpack puts the .wasm files in the bundle -app/main-view-model.ts the demo page: runs the suite, renders pass/fail +app/main-view-model.ts the demo page: runs the suite on both runtimes app/test.ts unit-test entry point (require.context over **/*.spec.ts) -app/tests/*.spec.ts the mocha specs +app/tests/wasm3/*.spec.ts the mocha specs for @cross-code/nativescript-wasm3 +app/tests/wamr/*.spec.ts the mocha specs for @cross-code/nativescript-wamr karma.conf.js mocha + chai frameworks, NativeScript launchers ``` @@ -39,27 +43,40 @@ ns test ios --device 73F3C71E-982C-4C2A-9AE3-CE75BC8FA2A2 ``` The specs load the real `test_types_bg.wasm` and `globals.wasm` and drive them -through `Wasm3Runtime` / `Wasm3Module` / `Wasm3Function`. What they cover: +through each plugin's public API — `Wasm3Runtime` / `Wasm3Module` / +`Wasm3Function` under `app/tests/wasm3/`, and `WamrRuntime` / `WamrModule` / +`WamrFunction` under `app/tests/wamr/`. + +Covered by both: - every value type in both directions, including i64 values past 2^53 that only survive because the bridge carries them as decimal strings; -- host imports — arguments arrive as the JS type the wasm3 signature declares, - and host return values flow back into wasm; +- host imports — arguments arrive as the JS type the signature declares, and + host return values flow back into wasm; - exported globals of all four types, read and written; - linear memory shared between wasm and the host; - loading a module from a path *and* from bytes; -- error mapping (`Wasm3Error`, with the Java exception prefix stripped), missing - exports, and imports left unlinked — which wasm3 reports at `findFunction`, - because it compiles lazily. +- error mapping (`Wasm3Error` / `WamrError`, with the Java exception prefix + stripped), missing exports, and imports left unlinked — which wasm3 reports at + `findFunction`, because it compiles lazily. + +WAMR adds, on top of that: + +- **execution tiers**: Interpreter (default), Fast JIT, LLVM JIT, and AOT — + selectable via `WamrExecutionTier` in `WamrRuntimeOptions`. Tiers that are not + compiled into the native build are skipped rather than failed; +- **WASI support** — the `wasiEnabled` runtime option, on its own and combined + with each tier; +- **custom stack sizes** — `stackSizeInBytes`. Calls into the fixture go through `callFixture()`, which types its arguments and result from the `.d.ts` wasm-pack generates from the Rust source (`@cross-code/nativescript-wasm-fixture/types`) — so passing a `number` where the Rust function takes an `i64` is a compile error, not a runtime surprise. -This is the only suite where the plugin's TypeScript adapters meet the real -native layer. The native code on its own is covered by the plugin's XCTest and -JUnit suites; the marshalling logic on its own by the plugin's vitest specs. +This is the only suite where either plugin's TypeScript adapters meet the real +native layer. The native code on its own is covered by each plugin's XCTest and +JUnit suites; the marshalling logic on its own by each plugin's vitest specs. Type-check without a device: @@ -70,7 +87,7 @@ npx nx typecheck nativescript-wasm-test ## Running the demo page ```bash -npm install # in this directory — links the two @org packages +npm install # in this directory — links the three @cross-code packages ns run ios ns run android ``` @@ -91,9 +108,9 @@ npm run build.wasm --prefix ../../packages/nativescript-wasm-fixture reports as a broken CocoaPods install. Export `LANG=en_US.UTF-8`. (The plugin ships a Swift package and needs no pods.) -**iOS: "the package at … `platforms/ios/NSCWasm3` cannot be accessed"** — a stale -`platforms/ios` from before the plugin's SPM path was made absolute. Delete -`platforms/` and re-run. +**iOS: "the package at … `platforms/ios/NSCWasm3` cannot be accessed"** (or +`NSCWamr`) — a stale `platforms/ios` from before the plugin's SPM path was made +absolute. Delete `platforms/` and re-run. **Android: "Cannot find a compatible Android SDK for compilation"** — the CLI supports up to `android-36`. Install it: @@ -104,6 +121,7 @@ Gradle intermediates. Clean them and this app's generated project: ```bash rm -rf ../../packages/nativescript-wasm3/platforms/android/wasm3-android/{,*/}build platforms/android +rm -rf ../../packages/nativescript-wamr/platforms/android/wamr-android/{,*/}build platforms/android ``` **Android: "native runtime not found — is the plugin installed and the app @@ -112,3 +130,14 @@ metadata generator skips the plugin's Kotlin classes because they are compiled with a newer Kotlin than it supports, so they never become visible to JavaScript. Check `platforms/android/build-tools/buildMetadata.log` for `Skip org.nativescript.wasm3.*`, and see the plugin's AGENTS.md. + +**WAMR specs fail with "native runtime not found"** — the WAMR plugin was not +previously shipped in a state the CLI could consume: it lacked the +`"nativescript"` field (added in commit 812d7da), so the CLI did not recognise +it as a plugin and did not add its `NSCWamr` Swift package to the Xcode +project. If the error persists, rebuild the app from a clean `platforms/` +directory and see `packages/nativescript-wamr/AGENTS.md`. + +## See also + +- [top-level README](../../README.md) — monorepo overview, test commands, and the shared plugin API. diff --git a/apps/nativescript-wasm-test/app/main-page.xml b/apps/nativescript-wasm-test/app/main-page.xml index 1798b58..75f98d4 100644 --- a/apps/nativescript-wasm-test/app/main-page.xml +++ b/apps/nativescript-wasm-test/app/main-page.xml @@ -1,5 +1,5 @@ - +