Thanks for contributing to @comapeo/core-react-native. This guide covers the
development setup, how to run the tests, and the commit/PR/release conventions.
For installation and usage of the published module see the README. For the architecture, process model, and directory-by-directory breakdown see AGENTS.md.
- Node.js 24 (see .nvmrc;
fnm useornvm usepicks it up). - For iOS work: macOS with Xcode. For Android work: Android Studio / SDK + NDK.
- For local end-to-end runs: Maestro
(
curl -fsSL "https://get.maestro.mobile.dev" | bash) and Java 17+.
npm install # install module deps and build the module (prepare)
npm run setup # fetch nodejs-mobile, build the backend, install the test appsnpm run setup runs, in order:
download:nodejs-mobile— pullsNodeMobile.xcframework(iOS) andlibnode.soper ABI plus headers (Android) into place; these are not committed. It fetches theliteruntime flavour, which drops ICU, the inspector,node:sqliteand TypeScript type-stripping — none of which we use.NODEJS_MOBILE_FLAVOR=fullfetches the full one; the two ship identical headers, so addon prebuilds work against either.backend:build— bundles the Node.js backend (backend/) that gets embedded in the app.npm installalone does not build it.- installs dependencies for the two test apps (
apps/integration,apps/e2e).
Re-run npm run setup after pulling changes that bump the nodejs-mobile version
or touch the backend. The individual steps are also available on their own
(npm run download:nodejs-mobile, npm run backend:build).
backend:build emits one bundle per platform, with its sourcemap relocated to a
sibling nodejs-sourcemaps/ directory so it never enters the APK/IPA.
Symbolication is server-side: @sentry/rollup-plugin embeds a content-derived
debug ID in the bundle, the consuming app uploads the maps with
comapeo-rn-upload-sourcemaps, and Sentry matches them by that ID.
The backend deliberately does not run with --enable-source-maps in any
variant. Measured on the Node 18 nodejs-mobile used to pin, findSourceMap()
re-parsed the whole map on every Error.stack format — roughly 320 ms and
250–470 MB of garbage per error for our 19 MB map, enough to wedge the event
loop for tens of seconds on a low-end device. Node has since reworked its
source-map cache; the flag stays off until that's re-measured on device. For a
stack you have in a terminal rather than in Sentry, comapeo-rn-symbolicate
remaps it offline from the shipped maps.
This is a single published npm package with its build tooling and test apps in the same repo. The short version (full detail in AGENTS.md):
src/,android/,ios/— the published module.backend/— the Node.js backend rolled up byscripts/build-backend.tsand embedded in the app.apps/integration/— Expo app used as the integration-test harness (it also runs as a dev app). Not published. Itsios/andandroid/trees are gitignored and regenerated byexpo prebuild; the source-of-truth test files live underapps/integration/tests/and are re-injected by the app-only config plugins.apps/e2e/+maestro/e2e.yaml— the end-to-end test app and its Maestro flow (run on BrowserStack in CI).scripts/— build tooling and the local Android test runner (run-instrumented-tests.sh).
Module and backend:
npm run build # compile the module's TypeScript (expo-module build)
npm run clean # remove build artifacts
npm run lint # ESLint
npm run backend:build # bundle the embedded Node.js backend (backend/)
npm run download:nodejs-mobile # re-fetch the nodejs-mobile runtime + headers
npm run open:ios # open the integration app in Xcode
npm run open:android # open the integration app in Android StudioBuilding the test apps. Each app's build:* script runs expo prebuild then a
Release build onto a connected simulator/emulator (after npm run setup has
installed its deps and built the embedded backend in via the config plugin):
npm run e2e:ios # e2e, iOS Release build + install (alias of apps/e2e build:ios)
npm run e2e:android # e2e, Android Release build + install
# the underlying per-app Release builds (each app has both):
npm --prefix apps/e2e run build:ios # e2e, iOS Release
npm --prefix apps/e2e run build:android # e2e, Android Release
npm --prefix apps/integration run build:ios # integration, iOS Release
npm --prefix apps/integration run build:android # integration, Android Releasebuild:ios (expo run:ios --configuration Release) and build:android
(expo run:android --variant release) build the Release configuration onto
a connected simulator/emulator — what the e2e suite drives locally (see below),
and handy for release-only behaviour and FGS cold-start timing (debug is ~10×
slower). They are not the BrowserStack artifacts: CI builds those separately in
e2e-reusable.yml — Android via
gradlew assembleRelease (an APK) and iOS via xcodebuild archive (an unsigned
arm64 device .ipa that can't run on a simulator at all).
The e2e app imports @comapeo/core-react-native (this repo) through a
node_modules/@comapeo/core-react-native symlink to the repo root — deliberately
not a tsconfig paths alias, which would make Metro load the module twice
and collide the two IPC singletons' RPC message IDs.
apps/e2e/scripts/link-local-module.js creates that symlink idempotently:
metro.config.js calls it when Metro loads, and the CI typecheck step runs it
directly, because tsc never loads Metro and expo prebuild doesn't create the
link either. If you get Cannot find module '@comapeo/core-react-native…' while
typechecking the e2e app, run node apps/e2e/scripts/link-local-module.js first.
For a plain dev/demo run with Metro and fast JS reload (not e2e — see
§"End-to-end locally" for why a dev build doesn't help
there), use an app's own ios / android / start scripts, e.g.
npm --prefix apps/integration run ios.
Run npm run lint and npm run test before opening a PR.
How the suites, workflows, and CI gating fit together (the merge queue, the e2e trust boundary, why some checks run where they do) is documented in docs/TESTING.md. This section is the short how-to.
Each of the seven test layers (see docs/TESTING.md §2 for what each one verifies) has a root script:
| Layer | Command | Notes |
|---|---|---|
| JS lint | npm run lint |
ESLint |
| JS unit | npm run test |
TypeScript unit tests (src/__tests__/) |
| Backend unit | npm run backend:test |
Node --test suite in backend/ |
| Swift package | npm run test:swift |
macOS, no simulator (cd ios && swift test) |
| JVM unit | npm run test:android:unit |
no device |
| Android instrumented | npm run test:android |
needs a booted emulator / device |
| iOS integration | npm run test:ios |
needs the integration app prebuilt + a simulator |
| End-to-end (local) | npm run e2e:ios / e2e:android, then npm run e2e:test |
Release build + Maestro; see below |
The native/integration commands are slower and platform-specific. The CI
workflows remain the source of truth for the exact flags and device matrix:
ios-tests.yml,
android-tests.yml,
e2e-tests.yml. test:android /
test:android:unit wrap
scripts/run-instrumented-tests.sh — call
the script directly to pass --class <TestClass> or --skip-build.
The e2e app runs its in-app suite on a local simulator/emulator — including
iOS. Locally you drive a Release build with the exact maestro/e2e.yaml
flow CI runs on BrowserStack:
npm run e2e:ios # or: npm run e2e:android — Release build + install onto a sim/emulator
npm run e2e:test # drive it with Maestro (maestro/e2e.yaml)A Release build disables the dev menu / LogBox overlays and loads its embedded
JS bundle (no Metro), so the local run needs no dev-build workarounds and matches
what CI tests. An expo run:ios Release build signs with your Apple development
team, so the embedded Node backend's keychain access works. (CI instead runs iOS
e2e on real BrowserStack devices from an unsigned device archive — a device
binary that can't run on a simulator regardless of signing, so don't try to run
the BrowserStack artifact locally.)
There is deliberately no dev-client e2e variant. A dev build's only advantage is fast JS reload, but nearly all of this module is native + nodejs-mobile code that needs a full rebuild on change anyway — so the dev-build path buys nothing here, and the Release build is the one worth testing.
For writing and debugging Maestro flows see the bundled
maestro-mobile-e2e skill.
Merges go through a required merge queue: a PR's required checks are evaluated on the queued merge commit before it lands, so the actual merged result is what gates the merge, and several PRs can land without each one re-running CI every time another merges.
The fast suites (JVM unit tests, Swift package tests, lint) run on every PR.
The heavy suites — Android instrumented, iOS integration + device build, and
the BrowserStack e2e — are slow/paid, so by default they run only in the merge
queue, not on every PR push: a plain PR gets cheap green checks and the real
runs happen when the PR is queued. To run the heavy suites on a PR before queueing
it (e.g. you touched device-facing code), add the run-e2e label or trigger
the workflow manually. See docs/TESTING.md §5 for the full
decision table.
The BrowserStack e2e needs secrets, which GitHub withholds from Dependabot and
fork PRs (so an untrusted dependency can't read them). Those PRs build but skip
the device run until a maintainer reviews the diff — including the Socket.dev
supply-chain report — and adds the safe-to-test label, which triggers the
isolated .github/workflows/e2e-trusted.yml.
The label is removed after the run and on every new commit, so a Dependabot bump
can never re-run secret-bearing CI without a fresh review.
PRs are merged with a merge commit. The release notes are generated from merged PRs, so the PR title drives the changelog (not the individual commits). Titles must follow Conventional Commits and are linted by .github/workflows/pr-title.yml:
<type>(<optional scope>): <subject>
Allowed types: feat, fix, perf, docs, refactor, test, chore,
ci, build. Use chore(deps): … for dependency bumps.
The workflow reads the type/scope and applies one changelog label, which .github/release.yml groups into the release notes:
| Title | Changelog section |
|---|---|
feat: … |
Features |
fix: … |
Bug Fixes |
perf: … |
Performance |
docs: … |
Documentation |
refactor / test / chore / ci / build |
Maintenance |
any …(deps): … |
Dependencies |
feat!: … or a BREAKING CHANGE: footer in the body |
Breaking Changes |
The label is derived from the title automatically — don't set the changelog
labels by hand; editing the title updates them. Add the skip-changelog label
to leave a PR out of the release notes entirely.
Dependency updates are opened by Dependabot (.github/dependabot.yml) with a 3-day cooldown, so nothing newer than 72 hours since publish is pulled in.
Releases are cut by the release workflow
(.github/workflows/release.yml), which wraps
optic-release-automation-action:
- Run the Sentry release smoke test — a
~10-minute manual checklist plus the scripted API tripwire
(
scripts/sentry-tripwire.mjs) that verify telemetry actually reaches Sentry from a test build. - Trigger the workflow manually (
workflow_dispatch) and pick the semver bump (auto,patch,minor,major, or apre*prerelease). This opens a release PR with the generated changelog. - Merging that PR builds and publishes to npm. Publishing runs on macOS because the tarball ships the per-addon iOS xcframeworks, which need Xcode tooling to assemble.
Prereleases publish under the pre npm tag from -pre. branches; stable
releases publish under latest from main.