diff --git a/.github/workflows/tests_e2e_android.yml b/.github/workflows/tests_e2e_android.yml index 9cf417ad24..ecffa974a1 100644 --- a/.github/workflows/tests_e2e_android.yml +++ b/.github/workflows/tests_e2e_android.yml @@ -227,6 +227,10 @@ jobs: max_attempts: 3 command: yarn tests:android:build + # JVM unit tests (no emulator). Produces module *.exec for merged Jacoco after Detox. + - name: Android JVM unit tests + run: yarn tests:android:unit + - name: Metro Bundler Cache Restore # https://github.com/actions/cache/releases uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 @@ -290,11 +294,12 @@ jobs: flags: e2e-ts-android verbose: true + # Merged unit (*.exec) + e2e (*.ec) from jacocoTestReport (post-e2e-coverage). - name: Upload Codecov Android native uses: codecov/codecov-action@e53489f4d376d79066609109e7a95a29eb3740b1 # v7.0.0 continue-on-error: true with: - files: tests/android/app/build/reports/jacoco/jacocoAndroidTestReport/jacocoAndroidTestReport.xml + files: tests/android/app/build/reports/jacoco/jacocoTestReport/jacocoTestReport.xml flags: android-native disable_search: true verbose: true diff --git a/okf-bundle/ci-workflows/android.md b/okf-bundle/ci-workflows/android.md index ed2c2616c8..ae82451673 100644 --- a/okf-bundle/ci-workflows/android.md +++ b/okf-bundle/ci-workflows/android.md @@ -2,11 +2,13 @@ ## E2E job shape (CI — mirrors workflow YAML; local: [running e2e](../testing/running-e2e.md)) -1. `tests:android:test-cover --headless` — pass/fail gate -2. `tests:android:post-e2e-coverage` — poll/pull `coverage.ec`, Jacoco report (best-effort, never fails the job) -3. **Codecov upload** — two flagged uploads (`e2e-ts-android`, `android-native`); `continue-on-error: true` on the action steps. **`codecov/project/android-native`** fails if the native flag upload is missing (see [coverage design](../testing/coverage-design.md#native-gates)). +1. `tests:android:build` +2. `tests:android:unit` — JVM unit (no emulator); produces module Jacoco `*.exec` ([AndroidTest-AD-1](../testing/android-architecture-decisions.md)) +3. `tests:android:test-cover --headless` — pass/fail gate +4. `tests:android:post-e2e-coverage` — poll/pull `coverage.ec`, merged **`jacocoTestReport`** (unit `*.exec` + e2e `*.ec`; best-effort, never fails the job) +5. **Codecov upload** — two flagged uploads (`e2e-ts-android`, `android-native` → `jacocoTestReport.xml`); `continue-on-error: true` on the action steps. **`codecov/project/android-native`** fails if the native flag upload is missing (see [coverage design](../testing/coverage-design.md#native-gates)). -Android native coverage is flushed in app process by `tests/app.js` Jet `after`; post-e2e pull runs after Detox exits. +Android native coverage is flushed in app process by `tests/app.js` Jet `after`; post-e2e pull runs after Detox exits and merges with JVM unit execution data. Full Jacoco contract: [coverage design](../testing/coverage-design.md) — do not duplicate here. ## CI failure: Jet 1006 → adb `reverse --remove` mid-run @@ -71,9 +73,10 @@ Under load, Jet may run only a small prefix before mocha-remote desync, often af | Symptom | Likely cause | |---------|----------------| | `[native-coverage] Android native coverage file not found after N attempts` | App-process flush did not run or failed; check Jet log for `[native-coverage] flushing android coverage` | -| Empty Jacoco XML (~235 bytes) | No `.ec` pulled — check post-e2e logs | +| Empty Jacoco XML (~235 bytes) | No `.ec` / `.exec` in merge — check post-e2e logs and that `yarn tests:android:unit` ran ([coverage design](../testing/coverage-design.md)) | + | `adb reverse --remove` in Detox logs | Expected on 1006; should be warn-only after Detox patch | | Detox red, tests green in log | Pre-patch: teardown adb error; re-run or check patch applied | | Emulator offline / hung / duplicate instance | Warm quickboot snapshot restore; `tests/.detoxrc.js` sets `bootArgs: '-no-snapshot-load -no-snapshot-save'` for cold boot when Detox launches TestingAVD | -| `codecov/project/android-native` fail | Jacoco XML not uploaded — check post-e2e logs and Codecov Uploads tab for `android-native` flag | +| `codecov/project/android-native` fail | Jacoco XML not uploaded — check post-e2e logs and Codecov Uploads tab for `android-native` flag; path must be `jacocoTestReport.xml` (merged), not e2e-only `jacocoAndroidTestReport.xml` | | FIS 503 / `Too many server requests` / RC cascade | Live cloud quota (shared project) — not Android-specific; see [cloud API quota triage](../testing/firebase-testing-project.md#ci-triage-cloud-api-quota-pressure) | diff --git a/okf-bundle/ci-workflows/index.md b/okf-bundle/ci-workflows/index.md index 30d360fbc2..8715764d4c 100644 --- a/okf-bundle/ci-workflows/index.md +++ b/okf-bundle/ci-workflows/index.md @@ -5,7 +5,7 @@ GitHub Actions job shape, platform reliability, and artifact triage. ## Platforms * [iOS](ios.md) — simulator boot, logging, troubleshooting, [CI baseload policy](ios.md#ci-baseload-policy-instrumentation) -* [Android](android.md) — idling, adb teardown, native coverage +* [Android](android.md) — JVM unit step, idling, adb teardown, merged Jacoco / native coverage * [Other](other.md) — macOS e2e, Windows/shared ## Shared E2E dependencies diff --git a/okf-bundle/index.md b/okf-bundle/index.md index 9f8441b228..7972741052 100644 --- a/okf-bundle/index.md +++ b/okf-bundle/index.md @@ -19,6 +19,7 @@ okf_version: '0.1' - [Running e2e tests](/testing/running-e2e.md) — canonical e2e commands, narrowing, environment, diagnosis - [Validation checklist](/testing/validation-checklist.md) — compile, Jest, lint, `compare:types`, e2e, coverage - [Published types ADR](/testing/architecture-decisions.md) — attw scope, Expo plugin checks, discarded resolutions +- [Android unit testing ADR](/testing/android-architecture-decisions.md) — Robolectric + Mockito JVM unit tests (`AndroidTest-AD-*`) - [Coverage design](/testing/coverage-design.md) — unit/e2e coverage policy, native gates, Codecov - [Firebase testing project](/testing/firebase-testing-project.md) — cloud vs emulator, live FIS/RC, helper callables, rules/indexes, deploy diff --git a/okf-bundle/testing/agent-command-policy.md b/okf-bundle/testing/agent-command-policy.md index e9539943b7..66e6946c98 100644 --- a/okf-bundle/testing/agent-command-policy.md +++ b/okf-bundle/testing/agent-command-policy.md @@ -26,17 +26,21 @@ Single source for **which shell commands agents may run** in this repo. E2e is a | Intent | Command | Never use instead | | --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------- | -| Install / refresh deps | `yarn` | `yarn workspace …`, `npm install` in a package, `yarn install` in `tests/` alone for root deps | +| Install / refresh deps | `yarn` (repo root) | `yarn workspace …`, `npm install`, `npm install` in a package, `yarn install` / `yarn` in `tests/` alone for root deps, skipping root `yarn` before e2e/build | | Transpile `lib/**` → `dist/module/**` (all packages) | `yarn lerna:prepare` | `yarn workspace @react-native-firebase/* prepare`, `cd packages/ && yarn prepare`, `cd packages/ && yarn run build` | | Transpile one package | `yarn lerna run prepare --scope @react-native-firebase/` | `yarn workspace @react-native-firebase/ prepare` | | After `packages/*/lib/**` edits (Metro serves `dist/module/**`) | `yarn lerna:prepare`; Metro restart when already running ([running e2e § prepare completion gate](running-e2e.md#prepare-completion-gate-blocking)) — platform `:build` only when [running e2e § Rules #3](running-e2e.md#rules) requires native/codegen/instrumentation, not for JS alone | ad-hoc `bob`, `babel`, or package-scoped prepare | | TS/JS validation sequence | [validation checklist](validation-checklist.md) | ad-hoc `tsc` in package dirs unless listed there | | JS lint (implementation / review gate) | `yarn lint:js`, `yarn lint:js --fix` | package-scoped `eslint`, `npx eslint` | +| Android Java format / lint | `yarn lint:android` | `yarn google-java-format`, bare `google-java-format`, `google-java-format -i`, `npx google-java-format`, any invented format script | | Docs lint (when docs in diff) | `yarn lint:markdown`, `yarn lint:spellcheck` | ad-hoc prettier/eslint on single files | -| E2e + coverage | [running e2e](running-e2e.md) — **only** `yarn tests:*` | `jet`, `npx jet`, `yarn jet`, `detox test`, `cd tests && …`, direct Metro/emulator starts | +| Android JVM unit tests | `yarn tests:android:unit` | ad-hoc `./gradlew …` outside this yarn script; bare Robolectric/JUnit IDE-only as the agent gate | +| Android merged Jacoco (unit + e2e) | `yarn tests:android:post-e2e-coverage` (after e2e); `yarn tests:android:test:jacoco-report` when regenerating the merge report | `./gradlew jacocoAndroidTestReport` as Codecov path; inventing other jacoco yarn scripts | +| E2e + coverage | [running e2e](running-e2e.md) — **only** `yarn tests:*` | `jet`, `npx jet`, `yarn jet`, `detox test`, bare `detox`, `cd tests && …`, direct Metro/emulator starts | | iOS Detox framework cache rebuild | `yarn tests:ios:detox-framework-cache:rebuild` | `cd tests && yarn detox clean-framework-cache`, `cd tests && yarn detox build-framework-cache`, bare `detox …` | | Host pre-flight (before each `:test-cover`) | [running e2e § host-clear probes](running-e2e.md#host-clear-probes) | `pgrep`, polling `:8090`, spawn probes of Jet/Detox | + ### Prepare / transpile (detail) `yarn lerna:prepare` runs each package's **`prepare`** script (`build` → `compile` via react-native-builder-bob). That is what produces **`dist/module/**`** consumed by Metro in debug e2e — only **release\*\* builds pre-bundle/embed JS ([running e2e § Rules #3](running-e2e.md#rules)). @@ -75,8 +79,11 @@ Single source for **which shell commands agents may run** in this repo. E2e is a | ---------------------------------------------------------------- | -------------------------------------------------------------- | | `yarn workspace @react-native-firebase/* prepare` (and variants) | Not canonical; breaks root devDependency binary resolution | | `cd packages/ && yarn prepare` / `yarn run build` | Same trap; not the postinstall / lerna code path | +| `yarn google-java-format`, bare `google-java-format`, `npx google-java-format`, `google-java-format -i` | Invented format entrypoints — **only** `yarn lint:android` | +| `npm install` (any cwd) / `yarn` / `yarn install` only in `tests/` for monorepo deps | Root `yarn` applies patches and workspace links; tests-only install is insufficient | +| Ad-hoc `./gradlew …` outside allowlisted yarn scripts (`tests:android:unit`, `tests:android:build`, `tests:android:post-e2e-coverage`, `tests:android:test:jacoco-report`, etc.) | Wrong task / cwd / report path; invents CI that does not match Codecov | | `yarn jet`, `npx jet`, `cd tests && yarn jet …` | [E2e agent rule](running-e2e.md#agent-rule-read-first) | -| `detox test`, `cd tests && detox …` | E2e agent rule | +| `detox test`, bare `detox`, `cd tests && detox …` | E2e agent rule | | Ad-hoc Metro / emulator start | Use `yarn tests:packager:jet`, `yarn tests:emulator:start` | | Spawn / PATH probes to “test” Jet or genversion | Log triage only; fix product code and re-run canonical command | @@ -95,6 +102,18 @@ Single source for **which shell commands agents may run** in this repo. E2e is a - **`yarn jet --help`** working or failing in `tests/` is **not** a valid e2e or install gate. - Jet is started **internally** by `yarn tests::test-cover`. Stale `:8090` → [pre-flight recovery](running-e2e.md#pre-flight-recovery), then re-run the same `:test-cover` command. +### Android Java format + +- There is **no** `yarn google-java-format` script. Invented `google-java-format` / `npx google-java-format` invocations are forbidden. +- **Canonical:** `yarn lint:android` (repo root) — wraps `google-java-format --set-exit-if-changed --replace` on `packages/*/android/src` and fails if the tree would change. + +### Android build / unit / Jacoco + +- **Do not** invent `cd tests && yarn install`, then bare `./gradlew` from an arbitrary cwd. +- Unit: **`yarn tests:android:unit`** only ([AndroidTest-AD-1](android-architecture-decisions.md#androidtest-ad-1--robolectric--mockito-for-android-jvm-unit-tests--accepted)). +- Merged coverage after e2e: **`yarn tests:android:post-e2e-coverage`** (Codecov path is `jacocoTestReport`, not e2e-only `jacocoAndroidTestReport`) — [coverage design](coverage-design.md). +- Optional explicit merge: **`yarn tests:android:test:jacoco-report`**. + ### TurboModule codegen - **`cd packages/ && yarn ios:codegen`** (or `yarn android:codegen`) often fails with **`unknown command 'codegen'`** after a clean `yarn` — `@react-native-community/cli` resolves from the **test app** workspace. @@ -109,9 +128,12 @@ Paste into Task / explore / work-queue prompts: RNFB agent command policy: okf-bundle/testing/agent-command-policy.md ONLY. E2e: okf-bundle/testing/running-e2e.md yarn tests:* ONLY. Never: yarn workspace prepare, yarn jet, npx jet, cd packages/* && yarn prepare/build for diagnostics. +Never invent format/install: yarn google-java-format, bare/npx google-java-format, npm install, yarn install in tests/ alone — use root yarn first; Java format = yarn lint:android ONLY. +Never invent Android Gradle: ad-hoc ./gradlew outside yarn tests:android:unit / :build / :post-e2e-coverage / :test:jacoco-report; bare detox/jet/metro. Prepare/install: yarn or yarn lerna:prepare must exit 0 before ANY other command — never parallelize with e2e/Metro/build. Area harness: okf-bundle/testing/running-e2e.md#local-harness-overrides-harnessoverridesjs — copy harness.overrides.example.js to gitignored harness.overrides.js; set modules + RNFBDebug; delete overrides after run. TurboModule contract test (NewArch-AD-17.1): packages/app/__tests__/nativeModuleContract.test.ts — yarn tests:jest -- packages/app/__tests__/nativeModuleContract.test.ts +Android JVM unit (AndroidTest-AD-1): yarn tests:android:unit — not a substitute for platform e2e. On failure: fix product code, re-run the same canonical command. Gate close / push: return [validation evidence package](validation-checklist.md#validation-evidence-package) and [coverage evidence package](coverage-design.md#coverage-evidence-package) when lib/native touched — required before commit or publication ([change authoring § validation evidence](change-authoring-workflow.md#validation-evidence-blocking)). ``` @@ -122,5 +144,6 @@ Gate close / push: return [validation evidence package](validation-checklist.md# | ------------------------------- | ------------------------------------------------------------ | | E2e commands, pre-flight, tiers | [running-e2e.md](running-e2e.md) | | Handoff validation sequence | [validation-checklist.md](validation-checklist.md) | +| Android JVM unit ADR | [android-architecture-decisions.md](android-architecture-decisions.md) | | Work types and gates | [change-authoring-workflow.md](change-authoring-workflow.md) | | Doc / commit policy | [documentation-policy.md](../documentation-policy.md) | diff --git a/okf-bundle/testing/android-architecture-decisions.md b/okf-bundle/testing/android-architecture-decisions.md new file mode 100644 index 0000000000..2c83a31f03 --- /dev/null +++ b/okf-bundle/testing/android-architecture-decisions.md @@ -0,0 +1,47 @@ +--- +type: Reference +title: Android unit testing decisions (ADR) +description: Canonical owner of durable decisions for Android JVM unit tests (Robolectric, Mockito, Jacoco *.exec). +tags: [testing, android, unit, robolectric, mockito, jacoco, adr] +timestamp: 2026-07-22T00:00:00Z +--- + +# Android unit testing decisions (ADR) + +**Canonical owner** of durable decisions for Android **JVM** unit tests under `packages/*/android/src/test/java`. Commands: [agent command policy](agent-command-policy.md), [validation checklist](validation-checklist.md). Coverage contract: [coverage design](coverage-design.md). CI shape: [Android CI workflows](../ci-workflows/android.md). + +**Policy:** [OKF documentation policy](../documentation-policy.md). Do not duplicate these decisions in work queues. + +## Decision ID convention + +Use the **`AndroidTest-AD-`** prefix when citing these decisions in code or docs. + +## Status legend + +| Status | Meaning | +|--------|---------| +| **Accepted** | Decided; CI and local yarn scripts enforce this. | +| **Proposed** | Planned; not yet enforced. | + +--- + + + + +## AndroidTest-AD-1 — Robolectric + Mockito for Android JVM unit tests — **Accepted** + +Use **Robolectric** (JUnit4 `RobolectricTestRunner`) and **Mockito** for Android Java state-machine / bridge logic that e2e cannot synthesize — for example multi-generation `ReactContext` overlap, Handler/Looper timing, and host/context doubles. + +| Aspect | Decision | +|--------|----------| +| Location | `packages/*/android/src/test/java/**` | +| Runner | JUnit4 + Robolectric (`@RunWith(RobolectricTestRunner.class)`) | +| Doubles | Mockito (`mock`, `mockStatic`, etc.) for RN host/context APIs | +| Entry command | `yarn tests:android:unit` → `tests/android` `./gradlew rnfbDebugUnitTests` | +| Coverage artifact | Module Jacoco `*.exec` (merged into Codecov via `jacocoTestReport` — [coverage design](coverage-design.md)) | + +**Why:** Detox/Jet e2e loads one live React Native generation and cannot reliably drive overlapping context generations, synthetic host wiring, or precise looper scheduling needed for some bridge state machines. JVM tests prove those contracts without an emulator. + +**Not a substitute for area e2e:** JVM tests do **not** replace [platform coverage gate](running-e2e.md#platform-coverage-gate-blocking) delivery/integration e2e on platforms where the module loads. Multi-generation races may be proven **primarily** via JVM tests; e2e remains required for load, flush, and native↔JS delivery on those platforms. + +**Formatting:** Java under `packages/*/android/src` uses **`yarn lint:android` only** — [agent command policy](agent-command-policy.md). diff --git a/okf-bundle/testing/architecture-decisions.md b/okf-bundle/testing/architecture-decisions.md index 84d1f16cbd..28f06e0ad9 100644 --- a/okf-bundle/testing/architecture-decisions.md +++ b/okf-bundle/testing/architecture-decisions.md @@ -10,6 +10,8 @@ timestamp: 2026-07-10T00:00:00Z **Canonical owner** of durable decisions for how RNFB validates published package types in CI. Procedure and commands: [validation checklist](validation-checklist.md). Implementation: `.attw.json`, `.github/scripts/attw/`, `tsconfig.consumer.json`. +**Related (separate owner):** Android JVM unit testing — [android-architecture-decisions.md](android-architecture-decisions.md) (`AndroidTest-AD-*`). + **Policy:** [OKF documentation policy](../documentation-policy.md). Do not duplicate these decisions in work queues. ## Decision ID convention diff --git a/okf-bundle/testing/change-authoring-workflow.md b/okf-bundle/testing/change-authoring-workflow.md index 622cd1e9d0..718aaf9c42 100644 --- a/okf-bundle/testing/change-authoring-workflow.md +++ b/okf-bundle/testing/change-authoring-workflow.md @@ -286,3 +286,4 @@ Ephemeral coordination (gate rows, `next_work_type`, `commit_subject`): **work q | E2e commands | [running-e2e.md](running-e2e.md) | | Validation commands | [validation-checklist.md](validation-checklist.md) | | Coverage policy | [coverage-design.md](coverage-design.md) | +| Android JVM unit ADR | [android-architecture-decisions.md](android-architecture-decisions.md) | diff --git a/okf-bundle/testing/coverage-design.md b/okf-bundle/testing/coverage-design.md index edc3d5b355..aa4aa64dd2 100644 --- a/okf-bundle/testing/coverage-design.md +++ b/okf-bundle/testing/coverage-design.md @@ -13,16 +13,20 @@ Coverage shows exercised **TS library sources** (`packages/*/lib/**`) and **nati | Layer | Proves | Consumers | |-------|--------|-----------| | **Unit (Jest)** | Package logic with mocks | Fast feedback on `lib/**` | +| **Unit (Android JVM)** | Java state-machine / bridge logic via Robolectric + Mockito ([AndroidTest-AD-1](android-architecture-decisions.md#androidtest-ad-1--robolectric--mockito-for-android-jvm-unit-tests--accepted)) | Fast feedback on `packages/*/android/**`; Jacoco `*.exec` | | **E2e (Jet / Detox)** | Real app behaviour against Firebase emulators and cloud APIs | TS + native bridge integration | Codecov merges CI uploads. Project-level % can be noise; **file-level changed-source coverage** is signal. macOS e2e uses firebase-js-sdk only; no RNFB native coverage. +**Android native coverage** merges JVM unit (`*.exec`) and e2e (`*.ec`) into **`jacocoTestReport`** — that merged XML is what Codecov `android-native` uploads. Lines exercised only by allowlisted Android unit tests **count** toward the 100% touched-line bar below. + # Coverage expectations (policy) For **new code**: * **Coverage only goes up** on files the change touches. * **100% on touched TS/native sources is the requirement**, not an aspiration. "Mostly covered" does not close the gate. +* **Android JVM unit Jacoco (`*.exec`)** counts toward that bar when allowlisted tests under `packages/*/android/src/test/java` exercise the touched lines — scope and e2e non-substitution: [AndroidTest-AD-1](android-architecture-decisions.md#androidtest-ad-1--robolectric--mockito-for-android-jvm-unit-tests--accepted); platforms where the module loads still need e2e ([platform coverage gate](running-e2e.md#platform-coverage-gate-blocking)). * **The only acceptable uncovered line is covered by an [acceptable exception](change-authoring-workflow.md#acceptable-exceptions)** — an evidence-backed intractable limitation, quantified (e.g. "~NN% provably-unreachable Swift codegen"), or a user-accepted deferral with recorded rationale. * **Every other gap is testable or dead code** — add the test (negative paths, failure branches, every reachable branch) or delete the unreachable/duplicate/superseded code. @@ -54,7 +58,7 @@ Produce after fresh e2e on every required platform, then post-process native art | Section | Contents | |---------|----------| -| **Artifacts** | Timestamps for `coverage/ios-native/lcov.info`, Android `jacocoAndroidTestReport.xml`, and any Jest coverage run | +| **Artifacts** | Timestamps for `coverage/ios-native/lcov.info`, Android merged `jacocoTestReport.xml` (unit `*.exec` + e2e `*.ec`), and any Jest coverage run | | **Touched regions** | Per-file or per-function line % for changed logic (not whole-package aggregates only) | | **Branch map** | Table: branch / input shape → test or e2e that exercises it | | **Gaps** | Every line or branch below 100%: **fix** (test or delete dead code), or **acceptable exception** with wire/runtime evidence in durable OKF (e.g. [pipeline platform parity](../packages/firestore/pipeline-platform-parity.md) probe row) | @@ -76,17 +80,18 @@ After `tests::test-cover`: * **JS:** `npx jest --coverage --collectCoverageFrom='packages//lib/**/*.ts' --coverageReporters=text` * **iOS native:** `yarn tests:ios:test:process-coverage` → `coverage/ios-native/lcov.info` (`DA:` lines). **Deletes processed `.profraw`** — re-run e2e before re-processing. -* **Android native:** `yarn tests:android:post-e2e-coverage` → Jacoco XML per `sourcefile`. **Deletes processed `emulator_coverage.ec`** after a successful report — re-run e2e before re-processing. +* **Android native:** `yarn tests:android:unit` (produces module `*.exec`) then e2e + `yarn tests:android:post-e2e-coverage` → merged **`jacocoTestReport`** XML per `sourcefile`. **Deletes processed `emulator_coverage.ec`** after a successful report — re-run e2e before re-processing. Unit-only: `yarn tests:android:test:jacoco-report` (same merged task; needs fresh `*.exec` and any available `*.ec`). * macOS e2e overwrites `coverage/lcov.info`; process iOS/Android native before a macOS run if you need both. ## Stale coverage data Native artifacts (`.profraw`, `.ec`, Jacoco XML) are trustworthy only with the fresh e2e run that produced them. Re-processing leftovers can create valid-looking stale reports. -If numbers look wrong, run the clean cycle before debugging generators — e2e steps: [running e2e § Rules](running-e2e.md#rules) and [typical loop](running-e2e.md#typical-loop); then post-process below (this doc owns post-e2e coverage export only): +If numbers look wrong, run the clean cycle before debugging generators — e2e steps: [running e2e § Rules](running-e2e.md#rules) and [typical loop](running-e2e.md#typical-loop); then post-process below (this doc owns post-e2e coverage export only). Android merge needs fresh unit `*.exec` as well — [Local iteration](#local-iteration): ```bash yarn tests:ios:test:process-coverage +yarn tests:android:unit # fresh module *.exec when Android native touched yarn tests:android:post-e2e-coverage ``` @@ -99,18 +104,22 @@ flowchart LR subgraph unit [Unit Jest] J1[jest --coverage] --> J2[coverage/lcov.info] end + subgraph android_jvm [Unit Android JVM] + U1[yarn tests:android:unit] --> EXEC["module *.exec"] + end subgraph ts_e2e [E2e TypeScript] M[Metro + inline source maps] --> A[App bundle] A --> J[Jet --coverage] J --> N[NYC remap] N --> T2[coverage/lcov.info] end - subgraph android_native [E2e Android native] + subgraph android_native [Android native Jacoco] D1[Detox e2e] --> FLA[RNFBTestingCoverage.flush] FLA --> EC[coverage.ec in app filesDir] EC --> P1[pull-native-coverage.js] - P1 --> J1R[jacocoAndroidTestReport] - J1R --> AX[jacoco XML] + EXEC --> P1 + P1 --> JTR[jacocoTestReport] + JTR --> AX[jacocoTestReport.xml] end subgraph ios_native [E2e iOS native] D2[Detox e2e] --> FLI[RNFBTestingCoverage.flush] @@ -125,6 +134,18 @@ flowchart LR I2 --> C ``` +# Unit coverage (Android JVM) + +```bash +yarn tests:android:unit +``` + +- Robolectric + Mockito under `packages/*/android/src/test/java` — [AndroidTest-AD-1](android-architecture-decisions.md#androidtest-ad-1--robolectric--mockito-for-android-jvm-unit-tests--accepted). +- Gradle entry: `tests/android` `./gradlew rnfbDebugUnitTests` (all RNFB library `:testDebugUnitTest` tasks). +- Output: Jacoco `*.exec` under each module `build/` (and app build tree as configured). +- **Counts toward** the 100% touched-line bar when allowlisted unit tests exercise those lines. +- Not a substitute for e2e on platforms where the module loads ([platform coverage gate](running-e2e.md#platform-coverage-gate-blocking)). + # Unit coverage (Jest) ```bash @@ -174,12 +195,13 @@ reporter: ['lcov', 'html', 'text-summary'], | iOS/Android merged 0, macOS OK | Prebuilt app bundle predates Istanbul fix | Re-run [running e2e § Rules](running-e2e.md#rules) (`:build` then `:test-cover`) | | Metro 500 on bundle | Missing babel plugins in `tests/` | `yarn install`; confirm `tests/node_modules/babel-plugin-istanbul` exists | -# E2e Android native (Jacoco) +# Android native (Jacoco — unit + e2e merged) -1. `testCoverageEnabled` on RNFB modules (`tests/android/build.gradle`). -2. Jet `after` in `tests/app.js` → `NativeModules.RNFBTestingCoverage.flush()` in **app** process → `coverage.ec` in `filesDir` **before** Detox SIGINT. -3. After Detox: `yarn tests:android:post-e2e-coverage` (or `pull-native-coverage`) → `emulator_coverage.ec` → `jacocoAndroidTestReport` → **delete local `.ec`**. Missing file: warning, not test/CI fail (`continue-on-error` on Codecov upload). Missing `.ec` on a later post-e2e without a new e2e run means Jacoco has no execution data — by design. -4. XML: `tests/android/app/build/reports/jacoco/jacocoAndroidTestReport/jacocoAndroidTestReport.xml` +1. `testCoverageEnabled` / Jacoco plugin on RNFB modules (`tests/android/build.gradle`) — e2e `*.ec` + unit `*.exec`. +2. **JVM unit:** `yarn tests:android:unit` before or independent of Detox — produces module `*.exec`. +3. Jet `after` in `tests/app.js` → `NativeModules.RNFBTestingCoverage.flush()` in **app** process → `coverage.ec` in `filesDir` **before** Detox SIGINT. +4. After Detox: `yarn tests:android:post-e2e-coverage` (or `pull-native-coverage --android-post-e2e`) → `emulator_coverage.ec` → **`jacocoTestReport`** (merged unit `*.exec` + e2e `*.ec`) → **delete local `.ec`**. Missing `.ec`: warning, not test/CI fail (`continue-on-error` on Codecov upload). Missing `.ec` on a later post-e2e without a new e2e run means the merge has no e2e execution data — by design; unit `*.exec` still merge if present. +5. XML uploaded to Codecov: `tests/android/app/build/reports/jacoco/jacocoTestReport/jacocoTestReport.xml` **Why app-process flush:** Detox SIGINT kills instrumentation after Jet; post-`Detox.runTests()` dump in `DetoxTest.java` never runs. @@ -188,7 +210,11 @@ reporter: ['lcov', 'html', 'text-summary'], - AGP 8 classes: `build/intermediates/javac/debug/compileDebugJavaWithJavac/classes` - Sources: include `src/reactnative/java` - Modules: `rootProject.ext.firebaseModulePaths` (`tests/android/build.gradle`) -- Tasks in `tests/android/app/jacoco.gradle`: `jacocoAndroidTestReport` (e2e `**/*.ec`, CI uses this), `jacocoUnitTestReport` (`**/*.exec`), `jacocoTestReport` (merged) +- Tasks in `tests/android/app/jacoco.gradle`: + - **`jacocoTestReport`** — merged unit + e2e (`**/*.exec` + `**/*.ec`); **CI / Codecov `android-native` uses this** + - `jacocoUnitTestReport` — unit only (`**/*.exec`) + - `jacocoAndroidTestReport` — e2e only (`**/*.ec`); local diagnostic, **not** the Codecov upload path +- Yarn: `tests:android:post-e2e-coverage` and `tests:android:test:jacoco-report` both drive `jacocoTestReport` # E2e iOS native (LLVM) @@ -222,7 +248,7 @@ Names must match in **`codecov.yml`** and workflow `flags:`. | `e2e-ts-ios` | `tests_e2e_ios.yml` (debug) | `coverage/lcov.info` | No | | `ios-native` | `tests_e2e_ios.yml` (debug) | `coverage/ios-native/lcov.info` | **Yes** | | `e2e-ts-android` | `tests_e2e_android.yml` | `coverage/lcov.info` | No | -| `android-native` | `tests_e2e_android.yml` | Jacoco XML path below | **Yes** | +| `android-native` | `tests_e2e_android.yml` | `tests/android/app/build/reports/jacoco/jacocoTestReport/jacocoTestReport.xml` | **Yes** | | `e2e-ts-macos` | `tests_e2e_other.yml` | `coverage/lcov.info` | No | iOS release legs: no upload. macOS: TS only. @@ -237,18 +263,21 @@ iOS release legs: no upload. macOS: TS only. |----------|-------| | `tests_jest.yml` | `yarn tests:jest-coverage` | | `tests_e2e_ios.yml` (debug) | Detox → `yarn tests:ios:test:process-coverage` (`continue-on-error: true` for now) | -| `tests_e2e_android.yml` | Detox → `yarn tests:android:post-e2e-coverage` | +| `tests_e2e_android.yml` | `yarn tests:android:build` → `yarn tests:android:unit` → Detox → `yarn tests:android:post-e2e-coverage` (merged `jacocoTestReport`) | | `tests_e2e_other.yml` | macOS Jet e2e | -**Paths:** JS `coverage/lcov.info`; iOS native `coverage/ios-native/lcov.info`; Android `tests/android/app/build/reports/jacoco/jacocoAndroidTestReport/jacocoAndroidTestReport.xml`. Uploads tab: **Processed** = good; **Unusable** = fix format/paths. +**Paths:** JS `coverage/lcov.info`; iOS native `coverage/ios-native/lcov.info`; Android merged native `tests/android/app/build/reports/jacoco/jacocoTestReport/jacocoTestReport.xml`. Uploads tab: **Processed** = good; **Unusable** = fix format/paths. # Local iteration -E2e per [runbook](running-e2e.md), then native post-processing: +E2e per [runbook](running-e2e.md), Android JVM unit + native post-processing: ```bash +yarn tests:android:unit yarn tests:ios:test:process-coverage -yarn tests:android:post-e2e-coverage +yarn tests:android:post-e2e-coverage # pulls .ec then jacocoTestReport (merged) +# optional explicit merge report without pull: +yarn tests:android:test:jacoco-report ``` Optional Codecov CLI: @@ -268,13 +297,16 @@ No `:test-cover-reuse` / `:test-reuse` — stale native risk ([runbook](running- |-----------|----------| | LLVM profile flags (iOS) | `Podfile` `post_install` | | Profile path at launch (iOS) | `AppDelegate` → `RNFBTestingConfigureCoverageProfilePath()` | -| Jacoco instrumentation (Android) | `testCoverageEnabled` in `tests/android/build.gradle` | +| Jacoco instrumentation (Android) | `testCoverageEnabled` + Jacoco plugin in `tests/android/build.gradle` | | Module name | `RNFBTestingCoverage` / `NativeModules.RNFBTestingCoverage` in `tests/app.js` | | Flush after Mocha | Jet `after` in `tests/app.js` | | Profraw pull before Detox teardown (iOS) | `pull-native-coverage.js` on Jet `close` in `firebase.test.js` | -| Android ec pull after Detox | `yarn tests:android:post-e2e-coverage` | +| Android JVM unit before / with merge | `yarn tests:android:unit` → module `*.exec` | +| Android ec pull after Detox | `yarn tests:android:post-e2e-coverage` → **`jacocoTestReport`** (not e2e-only `jacocoAndroidTestReport`) | +| Codecov android-native file | `jacocoTestReport/jacocoTestReport.xml` | | Fresh profraw processed (iOS) | `process-ios-native-coverage.js` deletes after export | | Fresh ec processed (Android) | `pull-native-coverage.js` deletes local `.ec` after successful Jacoco report | +| JVM unit ≠ e2e substitute | [AndroidTest-AD-1](android-architecture-decisions.md#androidtest-ad-1--robolectric--mockito-for-android-jvm-unit-tests--accepted); [platform coverage gate](running-e2e.md#platform-coverage-gate-blocking) still applies | # Troubleshooting @@ -283,10 +315,11 @@ No `:test-cover-reuse` / `:test-reuse` — stale native risk ([runbook](running- | "Open in 'testing'?" dialog | Custom URL scheme | Native module flush only | | No profraw; test passes | Pull in `afterAll` after cleanup, wrong module name | Pull on Jet `close`; verify export name | | Stale profraw uploaded | Re-process without re-e2e | Process deletes profraw; exit 1 if missing next time | -| Stale Android Jacoco / collapsed native % | Re-run `post-e2e-coverage` without fresh e2e | Post-e2e deletes `.ec` after report; run full `:build` → `:test-cover` → `:post-e2e-coverage` | +| Stale Android Jacoco / collapsed native % | Re-run `post-e2e-coverage` without fresh e2e (and/or without fresh unit `*.exec`) | Post-e2e deletes `.ec` after report; run `:build` → `tests:android:unit` → `:test-cover` → `:post-e2e-coverage` | | Coverage numbers suspect (any platform) | Leftover raw artifacts or reuse shortcuts | Full clean cycle per platform; see [Stale coverage data](#stale-coverage-data) | | No `packages/` hits in iOS export | Wrong binary / not instrumented | Re-run `tests:ios:build` per [running e2e § Rules](running-e2e.md#rules); check Podfile | -| Empty Jacoco XML (~235 B) | AGP 8 path, missing `src/reactnative/java`, no ec | Check post-e2e logs | +| Empty Jacoco XML (~235 B) | AGP 8 path, missing `src/reactnative/java`, no ec/exec | Check post-e2e logs; confirm `jacocoTestReport` not e2e-only task | +| Uploaded e2e-only Jacoco | Wrong report task / path | Codecov must use `jacocoTestReport.xml`, not `jacocoAndroidTestReport.xml` | | Android ec missing after pass | SIGINT before flush | `[native-coverage] flushing android coverage` in log; `MainApplication` registration | | Jet after: coverage not enabled | Release / non-instrumented build | Use `:test-cover` debug builds | | `swiftCompatibility56` undefined | Profile link flags on all Pods | App target only for `OTHER_LDFLAGS` | diff --git a/okf-bundle/testing/index.md b/okf-bundle/testing/index.md index e26eb261f9..0a642109a2 100644 --- a/okf-bundle/testing/index.md +++ b/okf-bundle/testing/index.md @@ -6,5 +6,7 @@ * [Iteration vocabulary](iteration-vocabulary.md) — work type, tier, and queue field identifiers * [Running e2e tests](running-e2e.md) — canonical e2e commands; start here for `:test-cover` * [Validation checklist](validation-checklist.md) — handoff command sequence -* [Coverage design](coverage-design.md) — coverage policy, Codecov/native gates +* [Coverage design](coverage-design.md) — coverage policy, Codecov/native gates (merged Android `jacocoTestReport`) +* [Android unit testing ADR](android-architecture-decisions.md) — Robolectric + Mockito (`AndroidTest-AD-1`) +* [Published types ADR](architecture-decisions.md) — attw / Expo plugin decisions (`Types-AD-*`) * [Firebase testing project](firebase-testing-project.md) — cloud vs emulator, live FIS/RC, helper callables, rules/indexes, deploy diff --git a/okf-bundle/testing/running-e2e.md b/okf-bundle/testing/running-e2e.md index 1bad3cc837..ab635218c6 100644 --- a/okf-bundle/testing/running-e2e.md +++ b/okf-bundle/testing/running-e2e.md @@ -57,7 +57,7 @@ yarn tests:macos:test-cover Clean `:build` + `:test-cover` each time — not reuse variants. -5. **Report locations** — [Coverage design](coverage-design.md). +5. **Report locations** — [Coverage design](coverage-design.md). Android CI also runs `yarn tests:android:unit` (JVM) before Detox; post-e2e produces merged **`jacocoTestReport`** (unit + e2e) — details there, not duplicated here. 6. **One e2e at a time** — never overlap `:test-cover` runs on one host. All platforms share Metro `:8081` and the test-runner WebSocket port (default **8090**); parallel runs race on coverage/device/emulator state. Every run starts after [clean pre-flight](#pre-flight-is-the-host-clear-to-start). Log triage for port/orchestration markers: [test-runner host orchestration](#test-runner-host-orchestration-log-triage-only). diff --git a/okf-bundle/testing/validation-checklist.md b/okf-bundle/testing/validation-checklist.md index 3a7141bfde..fdae1d0b79 100644 --- a/okf-bundle/testing/validation-checklist.md +++ b/okf-bundle/testing/validation-checklist.md @@ -73,6 +73,24 @@ yarn tests:jest --watchman=false packages/firestore/__tests__/pipelines.test.ts Optional: `yarn tests:jest-coverage`. +## Android JVM unit tests + +When `packages/*/android/**` Java bridge/state-machine logic changed (or added under `src/test/java`): + +```bash +yarn tests:android:unit # Robolectric + Mockito — [AndroidTest-AD-1](android-architecture-decisions.md#androidtest-ad-1--robolectric--mockito-for-android-jvm-unit-tests--accepted) +``` + +Produces Jacoco `*.exec` that **counts** toward native touched-line coverage when merged. After Android e2e: + +```bash +yarn tests:android:post-e2e-coverage # pull .ec → jacocoTestReport (unit + e2e merge) +# optional explicit merge: +yarn tests:android:test:jacoco-report +``` + +Merged Codecov path: `jacocoTestReport.xml` — [coverage design](coverage-design.md). JVM unit does **not** replace [platform coverage gate](running-e2e.md#platform-coverage-gate-blocking) e2e. + ## Lint and formatting @@ -83,6 +101,7 @@ Optional: `yarn tests:jest-coverage`. yarn lint:js # eslint packages/* — must exit 0 yarn lint:js --fix # auto-fix; re-run yarn lint:js until clean yarn lint:deps # dependency-cruiser no-circular on packages/*/lib/** — blocking when packages/*/lib/** in diff (see [prepare-and-cache § dependency-cycle linting](../monorepo-tooling/prepare-and-cache.md#dependency-cycle-linting)) +yarn lint:android # google-java-format on packages/*/android/src — ONLY entrypoint ([agent command policy](agent-command-policy.md)); never invent yarn google-java-format / npx google-java-format yarn format:js # inspect diff after; prefer lint:js --fix first ``` @@ -101,7 +120,7 @@ yarn lint # lint:js + lint:deps + lint:android + li When `packages/*/lib/**` is in the diff, **`yarn lint:deps`** is a **blocking** gate (runs inside `yarn lint`, which matches the CI Lint job). Config and rule detail: [prepare-and-cache § dependency-cycle linting](../monorepo-tooling/prepare-and-cache.md#dependency-cycle-linting). -`lint:android` runs `google-java-format` and fails if it would change committed files — commit formatter output. `lint:android` can flake; rerun once/twice if failure is not clearly in diff. +`yarn lint:android` runs `google-java-format` and fails if it would change committed files — commit formatter output. **Agents:** only `yarn lint:android` ([agent command policy](agent-command-policy.md)) — do not invent `yarn google-java-format` or bare/`npx` `google-java-format`. `lint:android` can flake; rerun once/twice if failure is not clearly in diff. **CI docs job equivalent** (required before `review` / publication when `docs/**` changed): @@ -114,7 +133,7 @@ yarn lint:spellcheck [Pre-flight](running-e2e.md#pre-flight-is-the-host-clear-to-start) (host-clear probes + services + harness tier) before every run — [agent command policy](agent-command-policy.md) and [e2e agent rule](running-e2e.md#agent-rule-read-first): use **only** `yarn tests:*` commands from [running e2e](running-e2e.md). Match harness to work type — **unit-focused**/**area-focused** never use full app load ([running e2e § harness](running-e2e.md#3-harness-matches-validation-tier)). -Commands: [Running e2e tests](running-e2e.md). Post-process: [Coverage design](coverage-design.md) (iOS `tests:ios:test:process-coverage`, Android `tests:android:post-e2e-coverage`). +Commands: [Running e2e tests](running-e2e.md). Post-process: [Coverage design](coverage-design.md) (iOS `tests:ios:test:process-coverage`, Android `tests:android:unit` + `tests:android:post-e2e-coverage` → merged `jacocoTestReport`). Some suites hit **cloud APIs**, e.g. Firestore Pipelines → `pipelines-e2e` Enterprise DB ([pipelines.md](../packages/firestore/pipelines.md#backend-cloud-enterprise-not-the-local-emulator)). @@ -139,9 +158,11 @@ Before closing **`implementation_gate`**, **`review_gate`**, **`commit_gate`**, | ------------------------- | ----------------------------- | ---- | --------------------------------------------------------------------------------------------------------------------------------- | | prepare | yarn lerna:prepare | 0 | — | | jest | yarn tests:jest | 0 | N/N tests | +| android JVM unit | yarn tests:android:unit | 0 | when packages/*/android/** Java changed — [AndroidTest-AD-1](android-architecture-decisions.md) | | e2e macOS | yarn tests:macos:test-cover | 0 | X passing — /tmp/...log | | e2e iOS | yarn tests:ios:test-cover | 0 | Y passing — /tmp/...log | | e2e Android | yarn tests:android:test-cover | 0 | Z passing — /tmp/...log | +| android merged Jacoco | yarn tests:android:post-e2e-coverage | 0 | jacocoTestReport.xml (unit + e2e) — [coverage design](coverage-design.md) | | compare:types | yarn compare:types | 0 | 0/0/0 | | lint (CI) | yarn lint | 0 | — | | lint:deps (lib diff) | yarn lint:deps | 0 | when packages/\*/lib/\*\* in diff — [dependency-cycle linting](../monorepo-tooling/prepare-and-cache.md#dependency-cycle-linting) | @@ -160,10 +181,12 @@ Before closing **`implementation_gate`**, **`review_gate`**, **`commit_gate`**, - [ ] `yarn reference:api` - [ ] Redirect audit when TypeDoc config changed ([documentation site maintenance § redirect audit](../documentation-site-maintenance.md#redirect-audit-required-when-typedoc-config-changes)) - [ ] `yarn tests:jest` +- [ ] `yarn tests:android:unit` when `packages/*/android/**` Java / `src/test/java` changed ([AndroidTest-AD-1](android-architecture-decisions.md)) - [ ] TurboModule wrapper contract ([NewArch-AD-17.1](../new-architecture/architecture-decisions.md#newarch-ad-171--jest-turbomodule-contract-test--accepted)) when `packages/app/lib/internal/registry/nativeModule.ts`, `nativeModuleAndroidIos.ts`, or TurboModule wrapper behavior changed: `yarn tests:jest -- packages/app/__tests__/nativeModuleContract.test.ts` - [ ] `yarn compare:types` (stale config entries removed) -- [ ] `yarn lint` (CI Lint job); **`yarn lint:deps`** when `packages/*/lib/**` in diff ([dependency-cycle linting](../monorepo-tooling/prepare-and-cache.md#dependency-cycle-linting)); `yarn lint:markdown` + `yarn lint:spellcheck` when `docs/**` changed +- [ ] `yarn lint` (CI Lint job) including **`yarn lint:android`** when Java changed ([agent command policy](agent-command-policy.md)); **`yarn lint:deps`** when `packages/*/lib/**` in diff ([dependency-cycle linting](../monorepo-tooling/prepare-and-cache.md#dependency-cycle-linting)); `yarn lint:markdown` + `yarn lint:spellcheck` when `docs/**` changed - [ ] E2e green on **every required platform** for the changed module ([platform coverage gate](running-e2e.md#platform-coverage-gate-blocking); [harness narrowing gate](running-e2e.md#harness-narrowing-gate-blocking); no `.only`; committed `RNFBDebug` remains `false`) +- [ ] Android post-e2e merged Jacoco when Android native touched: `yarn tests:android:post-e2e-coverage` → `jacocoTestReport.xml` ([coverage design](coverage-design.md)) - [ ] [Validation evidence package](validation-checklist.md#validation-evidence-package) recorded (exit codes, e2e counts, log paths) - [ ] [Coverage evidence package](coverage-design.md#coverage-evidence-package) when lib/native bridge touched — gaps investigated to fix, delete, or acceptable-exception bar - [ ] OKF bundle reviewed/updated per § above diff --git a/package.json b/package.json index f7b5120069..f294410991 100644 --- a/package.json +++ b/package.json @@ -52,9 +52,10 @@ "tests:android:test-reuse": "cd tests && yarn detox test --configuration android.emu.debug --reuse", "tests:android:test-cover": "cd tests && yarn detox test --configuration android.emu.debug --loglevel verbose", "tests:android:test-cover-reuse": "cd tests && yarn detox test --configuration android.emu.debug --reuse", + "tests:android:unit": "cd tests/android && ./gradlew rnfbDebugUnitTests", "tests:android:pull-native-coverage": "node tests/scripts/pull-native-coverage.js --android-pull", "tests:android:post-e2e-coverage": "node tests/scripts/pull-native-coverage.js --android-post-e2e", - "tests:android:test:jacoco-report": "cd tests/android && ./gradlew jacocoAndroidTestReport", + "tests:android:test:jacoco-report": "cd tests/android && ./gradlew jacocoTestReport", "tests:ios:build": "cd tests && yarn detox build --configuration ios.sim.debug", "tests:ios:build:release": "cd tests && yarn detox build --configuration ios.sim.release", "tests:ios:detox-framework-cache:rebuild": "cd tests && yarn detox rebuild-framework-cache", diff --git a/packages/app/android/build.gradle b/packages/app/android/build.gradle index 5b8e2eefa5..2268f725c0 100644 --- a/packages/app/android/build.gradle +++ b/packages/app/android/build.gradle @@ -95,6 +95,16 @@ android { java.excludes = ['**/generated/jni/**'] } } + + testOptions { + unitTests { + includeAndroidResources = true + all { + // Robolectric needs resources / manifests from the library + RN deps. + systemProperty 'robolectric.logging.enabled', 'true' + } + } + } } repositories { @@ -106,6 +116,10 @@ dependencies { implementation platform("com.google.firebase:firebase-bom:${ReactNative.ext.getVersion("firebase", "bom")}") implementation "com.google.firebase:firebase-common" implementation "com.google.android.gms:play-services-auth:${ReactNative.ext.getVersion("play", "play-services-auth")}" + + testImplementation "junit:junit:4.13.2" + testImplementation "org.robolectric:robolectric:4.14.1" + testImplementation "org.mockito:mockito-core:5.14.2" } def jvmVersion = Jvm.current().javaVersion?.majorVersion diff --git a/packages/app/android/src/reactnative/java/io/invertase/firebase/app/NativeRNFBTurboApp.java b/packages/app/android/src/reactnative/java/io/invertase/firebase/app/NativeRNFBTurboApp.java index af52527362..f4e10b7743 100644 --- a/packages/app/android/src/reactnative/java/io/invertase/firebase/app/NativeRNFBTurboApp.java +++ b/packages/app/android/src/reactnative/java/io/invertase/firebase/app/NativeRNFBTurboApp.java @@ -19,6 +19,7 @@ import android.util.Log; import com.facebook.fbreact.specs.NativeRNFBTurboAppSpec; +import com.facebook.react.bridge.LifecycleEventListener; import com.facebook.react.bridge.Promise; import com.facebook.react.bridge.ReactApplicationContext; import com.facebook.react.bridge.ReadableMap; @@ -35,7 +36,7 @@ import java.util.List; import java.util.Map; -public class NativeRNFBTurboApp extends NativeRNFBTurboAppSpec { +public class NativeRNFBTurboApp extends NativeRNFBTurboAppSpec implements LifecycleEventListener { private static final String TAG = "App"; public static Map authDomains = new HashMap<>(); @@ -47,10 +48,43 @@ public class NativeRNFBTurboApp extends NativeRNFBTurboAppSpec { @Override public void initialize() { super.initialize(); + ReactApplicationContext reactContext = getReactApplicationContext(); + // Register unconditionally. When the host is already RESUMED, addLifecycleEventListener + // posts onHostResume onto the UI queue asynchronously (it is not a synchronous call), so + // the explicit attach below is the primary path; resume remains best-effort convergence + // for later host flips. Stale-generation attaches are rejected or held pending by + // attachReactContext's fail-closed current-context arbitration. + reactContext.addLifecycleEventListener(this); + ReactNativeFirebaseEventEmitter.getSharedInstance().attachReactContext(reactContext); + } + + @Override + public void invalidate() { + ReactApplicationContext reactContext = getReactApplicationContext(); + reactContext.removeLifecycleEventListener(this); + // Identity-guarded inside the emitter: only clears state belonging to this dying context, + // never state a replacement runtime has already installed. + ReactNativeFirebaseEventEmitter.getSharedInstance().detachReactContext(reactContext); + super.invalidate(); + } + + @Override + public void onHostResume() { + // Re-attaching also flushes any events queued while no runtime was able to receive them. ReactNativeFirebaseEventEmitter.getSharedInstance() .attachReactContext(getReactApplicationContext()); } + @Override + public void onHostPause() { + // no-op, required by LifecycleEventListener + } + + @Override + public void onHostDestroy() { + // no-op, required by LifecycleEventListener + } + @Override protected Map getTypedExportedConstants() { Map constants = new HashMap<>(); diff --git a/packages/app/android/src/reactnative/java/io/invertase/firebase/common/ReactNativeFirebaseEventEmitter.java b/packages/app/android/src/reactnative/java/io/invertase/firebase/common/ReactNativeFirebaseEventEmitter.java index bbad4a2b00..60b98515af 100644 --- a/packages/app/android/src/reactnative/java/io/invertase/firebase/common/ReactNativeFirebaseEventEmitter.java +++ b/packages/app/android/src/reactnative/java/io/invertase/firebase/common/ReactNativeFirebaseEventEmitter.java @@ -17,27 +17,64 @@ * */ +import android.content.Context; import android.os.Handler; import android.os.Looper; import android.util.Log; import androidx.annotation.MainThread; +import androidx.annotation.Nullable; +import com.facebook.react.ReactApplication; +import com.facebook.react.ReactHost; +import com.facebook.react.ReactNativeHost; import com.facebook.react.bridge.Arguments; import com.facebook.react.bridge.ReactContext; import com.facebook.react.bridge.WritableMap; import com.facebook.react.modules.core.DeviceEventManagerModule; +import io.invertase.firebase.app.ReactNativeFirebaseApp; import io.invertase.firebase.interfaces.NativeEvent; +import java.lang.ref.WeakReference; import java.util.ArrayList; import java.util.HashMap; import java.util.List; public class ReactNativeFirebaseEventEmitter { + private static final String TAG = "RNFB_EMITTER"; private static ReactNativeFirebaseEventEmitter sharedInstance = new ReactNativeFirebaseEventEmitter(); - private final List queuedEvents = new ArrayList<>(); private final Handler handler = new Handler(Looper.getMainLooper()); + + // All mutable state below is guarded by the jsListeners monitor. Attach/detach/ready + // transitions happen synchronously at cause time under the same monitor as listener + // registration: with more than one ReactContext generation alive during an instance + // reload, deferring any of these transitions (e.g. to a congested main looper) reorders + // them against a replacement runtime's synchronous registrations and can wipe or wedge + // that runtime's state. Only queue flushing is posted to the main thread. + private final List queuedEvents = new ArrayList<>(); private final HashMap jsListeners = new HashMap<>(); - private ReactContext reactContext; - private Boolean jsReady = false; + // Weak so the emitter never pins a dead ReactContext (and its module graph) for the + // lifetime of the process if a runtime is torn down without a paired detach. + private WeakReference attachedReactContext = new WeakReference<>(null); + // When the host still names a dying generation, a live module's attach is remembered here + // and promoted once that dying context detaches or the host catches up — without waiting + // solely on a later Activity resume that may never arrive. + private WeakReference pendingReactContext = new WeakReference<>(null); + // Host identity we moved past while converging onto pending/attached; late attaches of this + // identity must not displace the live generation while the host ref lags. + private WeakReference hostLagReactContext = new WeakReference<>(null); + // When a new pending candidate enters while the attached generation still lives, listener + // accounting is reset so events queue until the pending JS re-registers. Keep a snapshot of + // the attached generation's accounting so a pending-only cancel can restore it — otherwise + // the surviving attached runtime stays deaf for the rest of the session (#8374-class). + @Nullable private HashMap attachedListenersSnapshot; + private boolean attachedJsReadySnapshot; + private int attachedJsListenerCountSnapshot; + // After pending-only cancel restores that snapshot, emit must keep targeting the restored + // attached generation even if the host has already advanced to an unrelated unattached + // context — otherwise resolveEmitContext prefers raw hostCurrent and silently loses events + // into a runtime with no RNFB JS subscribers (R1 class after cancel). Cleared when a new + // generation attaches through acceptAttachedContext / convergence / full detach. + private boolean emitPrefersRestoredAttached; + private boolean jsReady = false; private int jsListenerCount; public static ReactNativeFirebaseEventEmitter getSharedInstance() { @@ -45,26 +82,187 @@ public static ReactNativeFirebaseEventEmitter getSharedInstance() { } public void attachReactContext(final ReactContext reactContext) { - handler.post( - () -> { - ReactNativeFirebaseEventEmitter.this.reactContext = reactContext; - sendQueuedEvents(); - }); + // Resolve host current outside jsListeners so ReactApplication getters are not held + // under the listener monitor (they are RN-owned and not expected to re-enter, but the + // coupling is unnecessary for attach arbitration). + ReactContext hostCurrent = getCurrentReactContextFromHost(reactContext); + + synchronized (jsListeners) { + // Under the new architecture more than one ReactContext generation can exist while an + // instance is destroyed and recreated: a module belonging to the dying generation may + // run initialize() after the replacement generation has already attached (for example + // TurboModuleManager creates requested-but-never-built modules during invalidate). + // Attach is fail-closed when something is already attached: accept the candidate only + // when the host currently names it. A null host current keeps the previous attachment + // rather than accepting a late stale context. When the host still names the previous + // (dying) context, remember the candidate as pending so detach/host catch-up can + // converge without depending only on a later Activity resume. + ReactContext previousContext = attachedReactContext.get(); + if (previousContext != null && previousContext != reactContext) { + if (hostCurrent == null) { + Log.w( + TAG, + "Ignoring attach of ReactContext@" + + System.identityHashCode(reactContext) + + " while host current is null; keeping @" + + System.identityHashCode(previousContext)); + return; + } + + ReactContext hostLag = hostLagReactContext.get(); + if (hostCurrent == reactContext) { + if (hostLag != null && hostLag == reactContext) { + Log.w( + TAG, + "Ignoring attach of host-lag ReactContext@" + + System.identityHashCode(reactContext) + + "; keeping live @" + + System.identityHashCode(previousContext)); + return; + } + acceptAttachedContext( + reactContext, /* resetListenerState= */ pendingReactContext.get() != reactContext); + clearConvergenceState(); + discardAttachedListenerSnapshot(); + } else if (hostCurrent == previousContext) { + // Host still names the dying attached generation. Remember the candidate as pending + // so detach/host catch-up can converge. Idempotent re-attach of the same pending + // identity (e.g. async onHostResume while the host still lags) must not wipe + // listeners the pending JS already registered. Reset only when entering pending for + // a new candidate: first pending, or last-writer-wins replace of a different one. + ReactContext existingPending = pendingReactContext.get(); + boolean newPendingCandidate = existingPending != reactContext; + Log.i( + TAG, + "Host still on @" + + System.identityHashCode(previousContext) + + "; pending attach of @" + + System.identityHashCode(reactContext) + + (newPendingCandidate ? "" : " (idempotent)")); + pendingReactContext = new WeakReference<>(reactContext); + hostLagReactContext = new WeakReference<>(previousContext); + if (newPendingCandidate) { + // Drop previous-generation listener accounting; replacement JS will re-register. + // Snapshot once per pending window so pending-only cancel can restore the + // surviving attached generation (last-writer-wins replace keeps the first + // snapshot — that is still the attached runtime's pre-pending state). + snapshotAttachedListenerStateIfNeeded(); + resetListenerState(); + } + handler.post(this::tryConvergePendingReactContext); + return; + } else { + Log.w( + TAG, + "Ignoring attach of non-current ReactContext@" + + System.identityHashCode(reactContext) + + ", current is @" + + System.identityHashCode(hostCurrent)); + return; + } + } else if (previousContext != reactContext) { + acceptAttachedContext(reactContext, /* resetListenerState= */ false); + clearConvergenceState(); + } else { + // Same context re-attach (e.g. resume). Clear lag only when this identity is the + // live pending/attached generation the host has caught up to — not when we are + // still the dying hostLag while a different pending replacement is waiting. + if (hostCurrent == reactContext) { + ReactContext pending = pendingReactContext.get(); + if (pending == null || pending == reactContext) { + clearConvergenceState(); + } + } + } + } + + handler.post(this::sendQueuedEvents); + } + + public void detachReactContext(final ReactContext reactContext) { + synchronized (jsListeners) { + // Pending-only invalidate (failed replacement startup / overlapping teardown): the + // candidate never became attached, so cancel it before the attached-identity check. + // Restore the surviving attached generation's listener / jsReady snapshot taken when + // pending first entered — pending-window registrations belong to the cancelled + // generation and must not leave the attached runtime deaf. Superseded identities that + // are not the current pending fall through and no-op below — they must not wipe a + // newer live pending's registrations. + ReactContext pending = pendingReactContext.get(); + if (pending == reactContext && attachedReactContext.get() != reactContext) { + Log.i( + TAG, + "Cancelled pending ReactContext@" + + System.identityHashCode(reactContext) + + "; attached remains @" + + System.identityHashCode(attachedReactContext.get())); + clearConvergenceState(); + restoreAttachedListenerSnapshot(); + // Events queued while the pending window wiped listeners must drain now — attached + // JS already registered before pending entered and will not re-addListener. + handler.post(this::sendQueuedEvents); + return; + } + + // Only clear state installed by the runtime that is going away; a dying generation + // must not wipe listener registrations a replacement runtime has already made. A + // replacement runtime always attaches (synchronously, at module creation) before its + // JS can register listeners, so an identity match here means the dying state is ours. + if (attachedReactContext.get() != reactContext) { + return; + } + + if (pending != null && pending != reactContext) { + Log.i( + TAG, + "Detached ReactContext@" + + System.identityHashCode(reactContext) + + "; promoting pending @" + + System.identityHashCode(pending)); + attachedReactContext = new WeakReference<>(pending); + pendingReactContext = new WeakReference<>(null); + // Keep jsReady / jsListeners: they belong to the promoted generation's JS. + // hostLag stays set so emit and late attaches do not regress to the dying identity + // while the host ref still names it. + discardAttachedListenerSnapshot(); + emitPrefersRestoredAttached = false; + handler.post(this::sendQueuedEvents); + return; + } + + Log.i(TAG, "Detached ReactContext@" + System.identityHashCode(reactContext)); + attachedReactContext = new WeakReference<>(null); + resetListenerState(); + clearConvergenceState(); + discardAttachedListenerSnapshot(); + emitPrefersRestoredAttached = false; + // queuedEvents are intentionally kept: they drain once the next runtime attaches and + // registers listeners, which is how events raised while no runtime is alive (for + // example messaging events for a killed app) reach JS. + } } public void notifyJsReady(Boolean ready) { - handler.post( - () -> { - jsReady = ready; - sendQueuedEvents(); - }); + synchronized (jsListeners) { + jsReady = ready; + } + + handler.post(this::sendQueuedEvents); } public void sendEvent(final NativeEvent event) { handler.post( () -> { synchronized (jsListeners) { - if (!jsListeners.containsKey(event.getEventName()) || !emit(event)) { + if (!jsListeners.containsKey(event.getEventName())) { + queuedEvents.add(event); + return; + } + } + // emit() resolves ReactApplication host current outside jsListeners; do not hold + // the monitor across that call. + if (!emit(event)) { + synchronized (jsListeners) { queuedEvents.add(event); } } @@ -105,15 +303,36 @@ public WritableMap getListenersMap() { WritableMap writableMap = Arguments.createMap(); WritableMap events = Arguments.createMap(); - writableMap.putInt("listeners", jsListenerCount); - writableMap.putInt("queued", queuedEvents.size()); + // Resolve host current outside jsListeners; converge/snapshot under the monitor. + ReactContext pendingHint; + ReactContext attachedHint; + synchronized (jsListeners) { + pendingHint = pendingReactContext.get(); + attachedHint = attachedReactContext.get(); + } + ReactContext hostForConverge = + getCurrentReactContextFromHost(pendingHint != null ? pendingHint : attachedHint); + ReactContext attachedContext; synchronized (jsListeners) { + tryConvergePendingReactContextLocked(hostForConverge); + attachedContext = attachedReactContext.get(); + + writableMap.putInt("listeners", jsListenerCount); + writableMap.putInt("queued", queuedEvents.size()); + writableMap.putBoolean("jsReady", jsReady); + writableMap.putInt( + "attachedContextHash", + attachedContext == null ? 0 : System.identityHashCode(attachedContext)); + for (HashMap.Entry entry : jsListeners.entrySet()) { events.putInt(entry.getKey(), entry.getValue()); } } + ReactContext currentContext = getCurrentReactContextFromHost(attachedContext); + writableMap.putInt( + "currentContextHash", currentContext == null ? 0 : System.identityHashCode(currentContext)); writableMap.putMap("events", events); return writableMap; @@ -121,31 +340,267 @@ public WritableMap getListenersMap() { @MainThread private void sendQueuedEvents() { + List toSend; synchronized (jsListeners) { + toSend = new ArrayList<>(); for (NativeEvent event : new ArrayList<>(queuedEvents)) { if (jsListeners.containsKey(event.getEventName())) { queuedEvents.remove(event); - sendEvent(event); + toSend.add(event); } } } + for (NativeEvent event : toSend) { + sendEvent(event); + } } @MainThread private boolean emit(final NativeEvent event) { - if (!jsReady || reactContext == null || !reactContext.hasActiveCatalystInstance()) { + // Snapshot readiness / pointers under the monitor, resolve host current outside (avoid + // holding jsListeners across ReactApplication getters), then re-enter to converge/resolve. + ReactContext attachedHint; + boolean ready; + synchronized (jsListeners) { + ready = jsReady; + if (!ready) { + return false; + } + attachedHint = attachedReactContext.get(); + if (attachedHint == null) { + attachedHint = pendingReactContext.get(); + } + } + ReactContext hostCurrent = getCurrentReactContextFromHost(attachedHint); + + ReactContext emitContext; + synchronized (jsListeners) { + if (!jsReady) { + return false; + } + tryConvergePendingReactContextLocked(hostCurrent); + + // Resolve the context hosting the live JS runtime at emit time rather than trusting the + // attached one alone: a context captured at module-creation time can belong to a previous + // generation whose runtime no longer contains the JS listeners, and on bridgeless a stale + // context still reports an active instance so it cannot be validated - only replaced + // (#8374, #8900). When attached has already converged ahead of a lagging host ref, prefer + // attached over the dying host current so events are not delivered into a runtime with no + // listeners. + ReactContext attached = attachedReactContext.get(); + emitContext = resolveEmitContext(attached, hostCurrent); + } + + if (emitContext == null || !emitContext.hasActiveReactInstance()) { return false; } try { - reactContext + emitContext .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class) .emit("rnfb_" + event.getEventName(), event.getEventBody()); } catch (Exception e) { - Log.wtf("RNFB_EMITTER", "Error sending Event " + event.getEventName(), e); + Log.wtf(TAG, "Error sending Event " + event.getEventName(), e); return false; } return true; } + + @Nullable + private ReactContext resolveEmitContext( + @Nullable ReactContext attached, @Nullable ReactContext hostCurrent) { + ReactContext pending = pendingReactContext.get(); + ReactContext hostLag = hostLagReactContext.get(); + + // A pending replacement owns (or will own) the listener map. Prefer it over host/attached + // for the whole pending window — including when the host has already advanced to an + // unrelated third generation that has not yet attached via this emitter. Emitting into + // that third context would return true with no RNFB JS subscribers and no re-queue. + if (pending != null) { + return pending; + } + + if (hostCurrent == null) { + return attached; + } + if (attached == null || attached == hostCurrent) { + if (attached == hostCurrent) { + clearConvergenceState(); + emitPrefersRestoredAttached = false; + } + return hostCurrent; + } + + if (hostLag != null && hostLag == hostCurrent) { + return attached; + } + + // Pending-only cancel restored listeners onto attached; an unrelated hostCurrent that has + // not yet attached through this emitter must not steal delivery (silent loss, no re-queue). + // Flag is only set while an attached generation still owns the restored map. + if (emitPrefersRestoredAttached) { + return attached; + } + + // Attached is stale relative to a host that has moved on — prefer the host. + return hostCurrent; + } + + private void tryConvergePendingReactContext() { + ReactContext pendingHint; + synchronized (jsListeners) { + pendingHint = pendingReactContext.get(); + } + if (pendingHint == null) { + return; + } + ReactContext hostCurrent = getCurrentReactContextFromHost(pendingHint); + synchronized (jsListeners) { + if (tryConvergePendingReactContextLocked(hostCurrent)) { + handler.post(this::sendQueuedEvents); + } + } + } + + /** + * Prefer resolving {@code hostCurrent} outside {@code jsListeners} and passing it in so + * ReactApplication getters are not held under the listener monitor. + * + * @return true when attachment changed + */ + private boolean tryConvergePendingReactContextLocked(@Nullable ReactContext hostCurrent) { + ReactContext pending = pendingReactContext.get(); + if (pending == null) { + return false; + } + if (hostCurrent != pending) { + return false; + } + ReactContext previous = attachedReactContext.get(); + if (previous == pending) { + clearConvergenceState(); + return false; + } + Log.i( + TAG, "Host caught up; attaching pending ReactContext@" + System.identityHashCode(pending)); + acceptAttachedContext(pending, /* resetListenerState= */ false); + clearConvergenceState(); + discardAttachedListenerSnapshot(); + return true; + } + + private void acceptAttachedContext(ReactContext reactContext, boolean resetListenerState) { + ReactContext previousContext = attachedReactContext.get(); + if (previousContext != reactContext) { + Log.i( + TAG, + "ReactContext changed: @" + + System.identityHashCode(previousContext) + + " -> @" + + System.identityHashCode(reactContext)); + attachedReactContext = new WeakReference<>(reactContext); + // A real attach of a new generation supersedes restored-attached emit affinity. + emitPrefersRestoredAttached = false; + if (resetListenerState) { + discardAttachedListenerSnapshot(); + resetListenerState(); + } + } + } + + private void resetListenerState() { + jsReady = false; + jsListeners.clear(); + jsListenerCount = 0; + } + + private void snapshotAttachedListenerStateIfNeeded() { + if (attachedListenersSnapshot != null) { + return; + } + attachedListenersSnapshot = new HashMap<>(jsListeners); + attachedJsReadySnapshot = jsReady; + attachedJsListenerCountSnapshot = jsListenerCount; + } + + private void restoreAttachedListenerSnapshot() { + if (attachedListenersSnapshot == null) { + resetListenerState(); + emitPrefersRestoredAttached = false; + return; + } + jsListeners.clear(); + jsListeners.putAll(attachedListenersSnapshot); + jsReady = attachedJsReadySnapshot; + jsListenerCount = attachedJsListenerCountSnapshot; + discardAttachedListenerSnapshot(); + emitPrefersRestoredAttached = true; + } + + private void discardAttachedListenerSnapshot() { + attachedListenersSnapshot = null; + attachedJsReadySnapshot = false; + attachedJsListenerCountSnapshot = 0; + } + + private void clearConvergenceState() { + pendingReactContext = new WeakReference<>(null); + hostLagReactContext = new WeakReference<>(null); + } + + @Nullable + private static ReactContext getCurrentReactContextFromHost(@Nullable ReactContext contextHint) { + Context applicationContext = + contextHint != null + ? contextHint.getApplicationContext() + : ReactNativeFirebaseApp.getApplicationContext(); + if (applicationContext != null) { + // Normalize in case a non-application context (for example a ContentProvider context) + // was stored as the fallback. + applicationContext = applicationContext.getApplicationContext(); + } + if (!(applicationContext instanceof ReactApplication)) { + // Brownfield hosts that do not implement ReactApplication fall back to the attached + // context, preserving pre-existing behaviour. + return null; + } + + ReactApplication reactApplication = (ReactApplication) applicationContext; + + // Note: these getters may lazily construct their host on first call; that is acceptable + // here because a live react-native-firebase module implies the app's host already exists. + ReactContext fromReactHost = null; + try { + ReactHost reactHost = reactApplication.getReactHost(); + if (reactHost != null) { + fromReactHost = reactHost.getCurrentReactContext(); + if (fromReactHost != null) { + return fromReactHost; + } + // Hybrid / transitional hosts may expose a non-null ReactHost whose current context + // is briefly null while the bridge ReactNativeHost still carries the live context. + // Fall through rather than treating null as a permanent answer. + } + } catch (RuntimeException | LinkageError e) { + // App-authored hosts may throw, and react-native 0.83+ throws from the deprecated + // reactNativeHost getter on bridgeless-only apps. + Log.d(TAG, "Failed to resolve current ReactContext via ReactHost", e); + } + + try { + ReactNativeHost reactNativeHost = reactApplication.getReactNativeHost(); + // hasInstance() guards against getReactInstanceManager() eagerly creating an instance + // manager purely as a side effect of emitting an event. + if (reactNativeHost != null && reactNativeHost.hasInstance()) { + return reactNativeHost.getReactInstanceManager().getCurrentReactContext(); + } + } catch (RuntimeException | LinkageError e) { + // Bridgeless-only apps throw from getReactNativeHost(); if ReactHost was present but + // returned null current, that null stands — still log so the failure is visible. + Log.d(TAG, "Failed to resolve current ReactContext via ReactNativeHost", e); + } + + return fromReactHost; + } } diff --git a/packages/app/android/src/test/java/io/invertase/firebase/app/NativeRNFBTurboAppTest.java b/packages/app/android/src/test/java/io/invertase/firebase/app/NativeRNFBTurboAppTest.java new file mode 100644 index 0000000000..40e1834b2b --- /dev/null +++ b/packages/app/android/src/test/java/io/invertase/firebase/app/NativeRNFBTurboAppTest.java @@ -0,0 +1,400 @@ +package io.invertase.firebase.app; + +/* + * Copyright (c) 2016-present Invertase Limited & Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this library except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import android.app.Application; +import com.facebook.react.ReactApplication; +import com.facebook.react.ReactHost; +import com.facebook.react.ReactNativeHost; +import com.facebook.react.bridge.Arguments; +import com.facebook.react.bridge.Promise; +import com.facebook.react.bridge.ReactApplicationContext; +import com.facebook.react.bridge.ReactContext; +import com.facebook.react.bridge.ReadableMap; +import com.facebook.react.bridge.WritableMap; +import com.google.firebase.FirebaseApp; +import io.invertase.firebase.common.RCTConvertFirebase; +import io.invertase.firebase.common.ReactNativeFirebaseEventEmitter; +import io.invertase.firebase.common.ReactNativeFirebaseJSON; +import io.invertase.firebase.common.ReactNativeFirebaseMeta; +import io.invertase.firebase.common.ReactNativeFirebasePreferences; +import java.lang.ref.WeakReference; +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.MockedStatic; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; +import org.robolectric.shadows.ShadowLooper; + +/** + * JVM LINE coverage for {@link NativeRNFBTurboApp} — lifecycle, constants, app CRUD, events, and + * preferences bridges. + */ +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 34, application = NativeRNFBTurboAppTest.HostApplication.class) +public class NativeRNFBTurboAppTest { + + public static class HostApplication extends Application implements ReactApplication { + ReactHost reactHost; + + @Override + public ReactNativeHost getReactNativeHost() { + throw new RuntimeException("bridgeless-only test host"); + } + + @Override + public ReactHost getReactHost() { + return reactHost; + } + } + + private HostApplication application; + private ReactHost reactHost; + private ReactApplicationContext reactContext; + + @Before + public void setUp() throws Exception { + application = (HostApplication) org.robolectric.RuntimeEnvironment.getApplication(); + reactHost = mock(ReactHost.class); + application.reactHost = reactHost; + + reactContext = mock(ReactApplicationContext.class); + when(reactContext.getApplicationContext()).thenReturn(application); + when(reactContext.hasActiveReactInstance()).thenReturn(true); + when(reactHost.getCurrentReactContext()).thenReturn(reactContext); + + NativeRNFBTurboApp.authDomains.clear(); + resetEmitterSharedInstance(); + } + + @Test + public void initialize_registersLifecycleListenerAndAttachesEmitter() throws Exception { + NativeRNFBTurboApp module = new NativeRNFBTurboApp(reactContext); + module.initialize(); + ShadowLooper.idleMainLooper(); + + verify(reactContext).addLifecycleEventListener(module); + assertEquals(reactContext, attachedContext()); + } + + @Test + public void invalidate_removesLifecycleListenerAndDetachesEmitter() throws Exception { + NativeRNFBTurboApp module = new NativeRNFBTurboApp(reactContext); + ReactNativeFirebaseEventEmitter.getSharedInstance().attachReactContext(reactContext); + ShadowLooper.idleMainLooper(); + + module.invalidate(); + ShadowLooper.idleMainLooper(); + + verify(reactContext).removeLifecycleEventListener(module); + assertNull(attachedContext()); + } + + @Test + public void onHostResume_reattachesEmitter() throws Exception { + NativeRNFBTurboApp module = new NativeRNFBTurboApp(reactContext); + module.onHostResume(); + ShadowLooper.idleMainLooper(); + assertEquals(reactContext, attachedContext()); + } + + @Test + public void onHostPause_and_onHostDestroy_areNoOps() { + NativeRNFBTurboApp module = new NativeRNFBTurboApp(reactContext); + module.onHostPause(); + module.onHostDestroy(); + } + + @Test + public void getTypedExportedConstants_includesAppsAndRawJson() throws Exception { + FirebaseApp firebaseApp = mock(FirebaseApp.class); + Map appMap = new HashMap<>(); + appMap.put("name", "[DEFAULT]"); + + try (MockedStatic firebaseApps = mockStatic(FirebaseApp.class); + MockedStatic convert = mockStatic(RCTConvertFirebase.class); + MockedStatic jsonStatic = + mockStatic(ReactNativeFirebaseJSON.class)) { + firebaseApps + .when(() -> FirebaseApp.getApps(reactContext)) + .thenReturn(Collections.singletonList(firebaseApp)); + convert.when(() -> RCTConvertFirebase.firebaseAppToMap(firebaseApp)).thenReturn(appMap); + + ReactNativeFirebaseJSON json = mock(ReactNativeFirebaseJSON.class); + jsonStatic.when(ReactNativeFirebaseJSON::getSharedInstance).thenReturn(json); + when(json.getRawJSON()).thenReturn("{}"); + + NativeRNFBTurboApp module = new NativeRNFBTurboApp(reactContext); + Method method = NativeRNFBTurboApp.class.getDeclaredMethod("getTypedExportedConstants"); + method.setAccessible(true); + @SuppressWarnings("unchecked") + Map constants = (Map) method.invoke(module); + + assertEquals("{}", constants.get("FIREBASE_RAW_JSON")); + @SuppressWarnings("unchecked") + List> apps = + (List>) constants.get("NATIVE_FIREBASE_APPS"); + assertEquals(1, apps.size()); + assertEquals("[DEFAULT]", apps.get(0).get("name")); + } + } + + @Test + public void initializeApp_configuresAuthDomainAndResolvesPromise() { + ReadableMap options = mock(ReadableMap.class); + ReadableMap appConfig = mock(ReadableMap.class); + Promise promise = mock(Promise.class); + FirebaseApp firebaseApp = mock(FirebaseApp.class); + WritableMap writableMap = mock(WritableMap.class); + + when(appConfig.getString("name")).thenReturn("secondary"); + when(options.getString("authDomain")).thenReturn("example.firebaseapp.com"); + + try (MockedStatic convert = mockStatic(RCTConvertFirebase.class)) { + convert + .when(() -> RCTConvertFirebase.readableMapToFirebaseApp(options, appConfig, reactContext)) + .thenReturn(firebaseApp); + convert + .when(() -> RCTConvertFirebase.firebaseAppToWritableMap(firebaseApp)) + .thenReturn(writableMap); + + NativeRNFBTurboApp module = new NativeRNFBTurboApp(reactContext); + module.initializeApp(options, appConfig, promise); + + assertEquals("example.firebaseapp.com", NativeRNFBTurboApp.authDomains.get("secondary")); + verify(promise).resolve(writableMap); + } + } + + @Test + public void configureAuthDomain_putsAndRemovesEntries() { + NativeRNFBTurboApp.configureAuthDomain("secondary", "example.firebaseapp.com"); + assertEquals("example.firebaseapp.com", NativeRNFBTurboApp.authDomains.get("secondary")); + NativeRNFBTurboApp.configureAuthDomain("secondary", null); + assertFalse(NativeRNFBTurboApp.authDomains.containsKey("secondary")); + } + + @Test + public void setAutomaticDataCollectionEnabled_delegatesToFirebaseApp() { + FirebaseApp firebaseApp = mock(FirebaseApp.class); + try (MockedStatic firebaseApps = mockStatic(FirebaseApp.class)) { + firebaseApps.when(() -> FirebaseApp.getInstance("secondary")).thenReturn(firebaseApp); + + NativeRNFBTurboApp module = new NativeRNFBTurboApp(reactContext); + module.setAutomaticDataCollectionEnabled("secondary", true); + + verify(firebaseApp).setDataCollectionDefaultEnabled(true); + } + } + + @Test + public void deleteApp_deletesWhenInstancePresent() { + FirebaseApp firebaseApp = mock(FirebaseApp.class); + Promise promise = mock(Promise.class); + try (MockedStatic firebaseApps = mockStatic(FirebaseApp.class)) { + firebaseApps.when(() -> FirebaseApp.getInstance("secondary")).thenReturn(firebaseApp); + + NativeRNFBTurboApp module = new NativeRNFBTurboApp(reactContext); + module.deleteApp("secondary", promise); + + verify(firebaseApp).delete(); + verify(promise).resolve(null); + } + } + + @Test + public void deleteApp_resolvesWhenInstanceNull() { + Promise promise = mock(Promise.class); + try (MockedStatic firebaseApps = mockStatic(FirebaseApp.class)) { + firebaseApps.when(() -> FirebaseApp.getInstance("missing")).thenReturn(null); + + NativeRNFBTurboApp module = new NativeRNFBTurboApp(reactContext); + module.deleteApp("missing", promise); + + verify(promise).resolve(null); + } + } + + @Test + public void eventsNotifyReady_forwardsToEmitter() throws Exception { + NativeRNFBTurboApp module = new NativeRNFBTurboApp(reactContext); + module.eventsNotifyReady(true); + Field field = ReactNativeFirebaseEventEmitter.class.getDeclaredField("jsReady"); + field.setAccessible(true); + assertTrue(field.getBoolean(ReactNativeFirebaseEventEmitter.getSharedInstance())); + } + + @Test + public void eventsGetListeners_resolvesEmitterMap() { + Promise promise = mock(Promise.class); + WritableMap listenersMap = mock(WritableMap.class); + WritableMap eventsMap = mock(WritableMap.class); + + try (MockedStatic arguments = mockStatic(Arguments.class)) { + arguments.when(Arguments::createMap).thenReturn(listenersMap, eventsMap); + + NativeRNFBTurboApp module = new NativeRNFBTurboApp(reactContext); + module.eventsGetListeners(promise); + + verify(promise).resolve(listenersMap); + } + } + + @Test + public void eventsPing_sendsEventAndResolvesBody() { + ReadableMap body = mock(ReadableMap.class); + WritableMap writable = mock(WritableMap.class); + Promise promise = mock(Promise.class); + + try (MockedStatic convert = mockStatic(RCTConvertFirebase.class)) { + convert.when(() -> RCTConvertFirebase.readableMapToWritableMap(body)).thenReturn(writable); + + NativeRNFBTurboApp module = new NativeRNFBTurboApp(reactContext); + module.eventsPing("ping_event", body, promise); + + verify(promise).resolve(writable); + } + } + + @Test + public void eventsAddAndRemoveListener_delegateToEmitter() throws Exception { + NativeRNFBTurboApp module = new NativeRNFBTurboApp(reactContext); + module.eventsAddListener("auth_state_changed"); + ShadowLooper.idleMainLooper(); + + Field field = ReactNativeFirebaseEventEmitter.class.getDeclaredField("jsListeners"); + field.setAccessible(true); + @SuppressWarnings("unchecked") + Map listeners = + (Map) field.get(ReactNativeFirebaseEventEmitter.getSharedInstance()); + assertEquals(Integer.valueOf(1), listeners.get("auth_state_changed")); + + module.eventsRemoveListener("auth_state_changed", true); + assertFalse(listeners.containsKey("auth_state_changed")); + } + + @Test + public void addListener_and_removeListeners_areNoOpsForRnBuiltInEmitter() { + NativeRNFBTurboApp module = new NativeRNFBTurboApp(reactContext); + module.addListener("rn_builtin"); + module.removeListeners(1); + } + + @Test + public void metaGetAll_resolvesMetaSingleton() { + Promise promise = mock(Promise.class); + WritableMap all = mock(WritableMap.class); + ReactNativeFirebaseMeta meta = mock(ReactNativeFirebaseMeta.class); + when(meta.getAll()).thenReturn(all); + + try (MockedStatic metaStatic = + mockStatic(ReactNativeFirebaseMeta.class)) { + metaStatic.when(ReactNativeFirebaseMeta::getSharedInstance).thenReturn(meta); + + NativeRNFBTurboApp module = new NativeRNFBTurboApp(reactContext); + module.metaGetAll(promise); + verify(promise).resolve(all); + } + } + + @Test + public void jsonGetAll_resolvesJsonSingleton() { + Promise promise = mock(Promise.class); + WritableMap all = mock(WritableMap.class); + ReactNativeFirebaseJSON json = mock(ReactNativeFirebaseJSON.class); + when(json.getAll()).thenReturn(all); + + try (MockedStatic jsonStatic = + mockStatic(ReactNativeFirebaseJSON.class)) { + jsonStatic.when(ReactNativeFirebaseJSON::getSharedInstance).thenReturn(json); + + NativeRNFBTurboApp module = new NativeRNFBTurboApp(reactContext); + module.jsonGetAll(promise); + verify(promise).resolve(all); + } + } + + @Test + public void preferencesSettersAndGetters_delegateToPreferencesSingleton() { + Promise promise = mock(Promise.class); + WritableMap all = mock(WritableMap.class); + ReactNativeFirebasePreferences prefs = mock(ReactNativeFirebasePreferences.class); + when(prefs.getAll()).thenReturn(all); + + try (MockedStatic prefsStatic = + mockStatic(ReactNativeFirebasePreferences.class)) { + prefsStatic.when(ReactNativeFirebasePreferences::getSharedInstance).thenReturn(prefs); + + NativeRNFBTurboApp module = new NativeRNFBTurboApp(reactContext); + module.preferencesSetBool("k", true, promise); + verify(prefs).setBooleanValue("k", true); + verify(promise).resolve(null); + + module.preferencesSetString("k", "v", promise); + verify(prefs).setStringValue("k", "v"); + + module.preferencesGetAll(promise); + verify(promise).resolve(all); + + module.preferencesClearAll(promise); + verify(prefs).clearAll(); + } + } + + @Test + public void setLogLevel_isNoOp() { + NativeRNFBTurboApp module = new NativeRNFBTurboApp(reactContext); + module.setLogLevel("debug"); + } + + private static void resetEmitterSharedInstance() throws Exception { + Constructor ctor = + ReactNativeFirebaseEventEmitter.class.getDeclaredConstructor(); + ctor.setAccessible(true); + ReactNativeFirebaseEventEmitter fresh = ctor.newInstance(); + Field shared = ReactNativeFirebaseEventEmitter.class.getDeclaredField("sharedInstance"); + shared.setAccessible(true); + shared.set(null, fresh); + } + + @SuppressWarnings("unchecked") + private static ReactContext attachedContext() throws Exception { + ReactNativeFirebaseEventEmitter emitter = ReactNativeFirebaseEventEmitter.getSharedInstance(); + Field field = ReactNativeFirebaseEventEmitter.class.getDeclaredField("attachedReactContext"); + field.setAccessible(true); + WeakReference ref = (WeakReference) field.get(emitter); + return ref.get(); + } +} diff --git a/packages/app/android/src/test/java/io/invertase/firebase/common/ReactNativeFirebaseEventEmitterTest.java b/packages/app/android/src/test/java/io/invertase/firebase/common/ReactNativeFirebaseEventEmitterTest.java new file mode 100644 index 0000000000..113c9a3242 --- /dev/null +++ b/packages/app/android/src/test/java/io/invertase/firebase/common/ReactNativeFirebaseEventEmitterTest.java @@ -0,0 +1,1823 @@ +package io.invertase.firebase.common; + +/* + * Copyright (c) 2016-present Invertase Limited & Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this library except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import android.app.Application; +import android.content.Context; +import androidx.annotation.Nullable; +import com.facebook.react.ReactApplication; +import com.facebook.react.ReactHost; +import com.facebook.react.ReactInstanceManager; +import com.facebook.react.ReactNativeHost; +import com.facebook.react.bridge.Arguments; +import com.facebook.react.bridge.ReactContext; +import com.facebook.react.bridge.WritableMap; +import com.facebook.react.modules.core.DeviceEventManagerModule; +import io.invertase.firebase.app.ReactNativeFirebaseApp; +import io.invertase.firebase.interfaces.NativeEvent; +import java.lang.ref.WeakReference; +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.HashMap; +import java.util.concurrent.atomic.AtomicBoolean; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.MockedStatic; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; +import org.robolectric.shadows.ShadowLooper; + +/** + * JVM coverage for {@link ReactNativeFirebaseEventEmitter} ReactContext generation overlap. + * + *

Uses Robolectric for {@link android.os.Handler}/{@link android.os.Looper} and Mockito for RN + * host/context doubles. The contracts under test are Java state-machine behaviour and do not + * require the Detox/Jet harness. + */ +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 34, application = ReactNativeFirebaseEventEmitterTest.HostApplication.class) +public class ReactNativeFirebaseEventEmitterTest { + + private static final String EVENT = "auth_state_changed"; + + private HostApplication application; + private ReactHost reactHost; + private ReactContext staleContext; + private ReactContext liveContext; + private DeviceEventManagerModule.RCTDeviceEventEmitter staleJsEmitter; + private DeviceEventManagerModule.RCTDeviceEventEmitter liveJsEmitter; + private WritableMap eventBody; + + public static class HostApplication extends Application implements ReactApplication { + ReactHost reactHost; + ReactNativeHost reactNativeHost; + + @Override + public ReactNativeHost getReactNativeHost() { + if (reactNativeHost == null) { + throw new RuntimeException("bridgeless-only test host"); + } + return reactNativeHost; + } + + @Override + public ReactHost getReactHost() { + return reactHost; + } + } + + @Before + public void setUp() throws Exception { + application = (HostApplication) org.robolectric.RuntimeEnvironment.getApplication(); + reactHost = mock(ReactHost.class); + application.reactHost = reactHost; + application.reactNativeHost = null; + + staleContext = mock(ReactContext.class); + liveContext = mock(ReactContext.class); + staleJsEmitter = mock(DeviceEventManagerModule.RCTDeviceEventEmitter.class); + liveJsEmitter = mock(DeviceEventManagerModule.RCTDeviceEventEmitter.class); + eventBody = mock(WritableMap.class); + + when(staleContext.getApplicationContext()).thenReturn(application); + when(liveContext.getApplicationContext()).thenReturn(application); + // Bridgeless stale contexts still report an active instance via host-level state. + when(staleContext.hasActiveReactInstance()).thenReturn(true); + when(liveContext.hasActiveReactInstance()).thenReturn(true); + when(staleContext.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class)) + .thenReturn(staleJsEmitter); + when(liveContext.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class)) + .thenReturn(liveJsEmitter); + + resetSharedInstance(); + } + + /** + * Emit must target the host's current ReactContext, not a previously attached generation that no + * longer hosts JS listeners. + */ + @Test + public void emit_deliversToHostCurrentContext_whenAttachedContextIsStale() { + when(reactHost.getCurrentReactContext()).thenReturn(staleContext); + + ReactNativeFirebaseEventEmitter emitter = ReactNativeFirebaseEventEmitter.getSharedInstance(); + emitter.attachReactContext(staleContext); + ShadowLooper.idleMainLooper(); + + when(reactHost.getCurrentReactContext()).thenReturn(liveContext); + + emitter.notifyJsReady(true); + ShadowLooper.idleMainLooper(); + emitter.addListener(EVENT); + ShadowLooper.idleMainLooper(); + + emitter.sendEvent(event(EVENT, eventBody)); + ShadowLooper.idleMainLooper(); + + verify(liveJsEmitter).emit(eq("rnfb_" + EVENT), eq(eventBody)); + verify(staleJsEmitter, never()).emit(anyString(), eq(eventBody)); + } + + /** + * When the host cannot name a current context (reload gap), a late attach from a dying generation + * must not displace the live attachment, and a subsequent detach of that dying context must not + * clear live listener / jsReady state. + */ + @Test + public void attachDetach_preservesLiveListeners_whenHostCurrentNullAndStaleOverlaps() + throws Exception { + when(reactHost.getCurrentReactContext()).thenReturn(liveContext); + + ReactNativeFirebaseEventEmitter emitter = ReactNativeFirebaseEventEmitter.getSharedInstance(); + emitter.attachReactContext(liveContext); + ShadowLooper.idleMainLooper(); + + emitter.notifyJsReady(true); + ShadowLooper.idleMainLooper(); + emitter.addListener(EVENT); + ShadowLooper.idleMainLooper(); + + assertEquals(1, listenerCount(emitter, EVENT)); + assertTrue(jsReady(emitter)); + + when(reactHost.getCurrentReactContext()).thenReturn(null); + emitter.attachReactContext(staleContext); + ShadowLooper.idleMainLooper(); + + emitter.detachReactContext(staleContext); + ShadowLooper.idleMainLooper(); + + assertEquals( + "live jsListeners must survive late stale attach+detach when host current is null", + 1, + listenerCount(emitter, EVENT)); + assertTrue( + "jsReady must survive late stale attach+detach when host current is null", + jsReady(emitter)); + assertEquals( + "attached context must remain the live generation", liveContext, attachedContext(emitter)); + assertDiagnostics(emitter, /* jsReady= */ true, liveContext, /* current= */ null); + } + + /** + * Live module initialize while the host still names the dying context must converge: pending + * attach is promoted on dying detach, listeners registered by the live JS survive, and emit + * prefers the promoted attachment over the lagging host current. + */ + @Test + public void attachDetachEmit_convergesToLiveContext_whenHostLagsOnDyingGeneration() + throws Exception { + when(reactHost.getCurrentReactContext()).thenReturn(staleContext); + + ReactNativeFirebaseEventEmitter emitter = ReactNativeFirebaseEventEmitter.getSharedInstance(); + emitter.attachReactContext(staleContext); + ShadowLooper.idleMainLooper(); + + emitter.attachReactContext(liveContext); + ShadowLooper.idleMainLooper(); + assertEquals(staleContext, attachedContext(emitter)); + assertEquals(liveContext, pendingContext(emitter)); + + emitter.notifyJsReady(true); + ShadowLooper.idleMainLooper(); + emitter.addListener(EVENT); + ShadowLooper.idleMainLooper(); + + emitter.detachReactContext(staleContext); + ShadowLooper.idleMainLooper(); + + assertEquals(liveContext, attachedContext(emitter)); + assertEquals(1, listenerCount(emitter, EVENT)); + assertTrue(jsReady(emitter)); + + // Host still names dying generation; emit must not deliver into it. + emitter.sendEvent(event(EVENT, eventBody)); + ShadowLooper.idleMainLooper(); + + verify(liveJsEmitter).emit(eq("rnfb_" + EVENT), eq(eventBody)); + verify(staleJsEmitter, never()).emit(anyString(), eq(eventBody)); + + assertDiagnostics(emitter, /* jsReady= */ true, liveContext, staleContext); + } + + /** + * After the live generation has converged ahead of the host, a late attach of the lagging host + * current must not overwrite the live attachment. + */ + @Test + public void attach_rejectsHostLagContext_afterLiveGenerationPromoted() throws Exception { + when(reactHost.getCurrentReactContext()).thenReturn(staleContext); + + ReactNativeFirebaseEventEmitter emitter = ReactNativeFirebaseEventEmitter.getSharedInstance(); + emitter.attachReactContext(staleContext); + ShadowLooper.idleMainLooper(); + emitter.attachReactContext(liveContext); + ShadowLooper.idleMainLooper(); + emitter.notifyJsReady(true); + emitter.addListener(EVENT); + ShadowLooper.idleMainLooper(); + emitter.detachReactContext(staleContext); + ShadowLooper.idleMainLooper(); + + emitter.attachReactContext(staleContext); + ShadowLooper.idleMainLooper(); + + assertEquals(liveContext, attachedContext(emitter)); + assertEquals(1, listenerCount(emitter, EVENT)); + assertTrue(jsReady(emitter)); + } + + /** + * When the host catches up to a pending live context before the dying generation detaches, + * attachment converges without wiping listener registrations already made by live JS. + */ + @Test + public void attach_convergesPending_whenHostCatchesUpBeforeDyingDetach() throws Exception { + when(reactHost.getCurrentReactContext()).thenReturn(staleContext); + + ReactNativeFirebaseEventEmitter emitter = ReactNativeFirebaseEventEmitter.getSharedInstance(); + emitter.attachReactContext(staleContext); + ShadowLooper.idleMainLooper(); + emitter.attachReactContext(liveContext); + ShadowLooper.idleMainLooper(); + + emitter.notifyJsReady(true); + emitter.addListener(EVENT); + ShadowLooper.idleMainLooper(); + + when(reactHost.getCurrentReactContext()).thenReturn(liveContext); + emitter.attachReactContext(liveContext); + ShadowLooper.idleMainLooper(); + + assertEquals(liveContext, attachedContext(emitter)); + assertEquals(1, listenerCount(emitter, EVENT)); + assertTrue(jsReady(emitter)); + assertEquals(null, pendingContext(emitter)); + + emitter.sendEvent(event(EVENT, eventBody)); + ShadowLooper.idleMainLooper(); + verify(liveJsEmitter).emit(eq("rnfb_" + EVENT), eq(eventBody)); + } + + /** + * A confirmed host switch onto a new context resets listener and jsReady accounting that belonged + * to the previous generation. + */ + @Test + public void attach_resetsListenerState_onHostConfirmedContextSwitch() throws Exception { + when(reactHost.getCurrentReactContext()).thenReturn(staleContext); + + ReactNativeFirebaseEventEmitter emitter = ReactNativeFirebaseEventEmitter.getSharedInstance(); + emitter.attachReactContext(staleContext); + ShadowLooper.idleMainLooper(); + emitter.notifyJsReady(true); + emitter.addListener(EVENT); + ShadowLooper.idleMainLooper(); + + when(reactHost.getCurrentReactContext()).thenReturn(liveContext); + emitter.attachReactContext(liveContext); + ShadowLooper.idleMainLooper(); + + assertEquals(liveContext, attachedContext(emitter)); + assertEquals(0, listenerCount(emitter, EVENT)); + assertFalse(jsReady(emitter)); + assertDiagnostics(emitter, /* jsReady= */ false, liveContext, liveContext); + } + + /** + * Dying detach after the pointer already moved to the live generation is a no-op for listener + * state. + */ + @Test + public void detach_isNoOp_whenAttachedPointerAlreadyMoved() throws Exception { + when(reactHost.getCurrentReactContext()).thenReturn(liveContext); + + ReactNativeFirebaseEventEmitter emitter = ReactNativeFirebaseEventEmitter.getSharedInstance(); + emitter.attachReactContext(staleContext); + ShadowLooper.idleMainLooper(); + // Force live attach while host already names live (confirmed switch). + emitter.attachReactContext(liveContext); + ShadowLooper.idleMainLooper(); + emitter.notifyJsReady(true); + emitter.addListener(EVENT); + ShadowLooper.idleMainLooper(); + + emitter.detachReactContext(staleContext); + ShadowLooper.idleMainLooper(); + + assertEquals(liveContext, attachedContext(emitter)); + assertEquals(1, listenerCount(emitter, EVENT)); + assertTrue(jsReady(emitter)); + } + + /** + * When ReactHost is present but reports a null current context, resolution falls through to + * ReactNativeHost so hybrid hosts still participate in arbitration. + */ + @Test + public void hostResolution_fallsThroughToReactNativeHost_whenReactHostCurrentNull() + throws Exception { + ReactNativeHost reactNativeHost = mock(ReactNativeHost.class); + ReactInstanceManager instanceManager = mock(ReactInstanceManager.class); + application.reactNativeHost = reactNativeHost; + when(reactHost.getCurrentReactContext()).thenReturn(null); + when(reactNativeHost.hasInstance()).thenReturn(true); + when(reactNativeHost.getReactInstanceManager()).thenReturn(instanceManager); + when(instanceManager.getCurrentReactContext()).thenReturn(liveContext); + + ReactNativeFirebaseEventEmitter emitter = ReactNativeFirebaseEventEmitter.getSharedInstance(); + emitter.attachReactContext(staleContext); + ShadowLooper.idleMainLooper(); + emitter.notifyJsReady(true); + emitter.addListener(EVENT); + ShadowLooper.idleMainLooper(); + + // Confirmed switch via bridge host fallthrough. + emitter.attachReactContext(liveContext); + ShadowLooper.idleMainLooper(); + + assertEquals(liveContext, attachedContext(emitter)); + assertEquals(0, listenerCount(emitter, EVENT)); + assertFalse(jsReady(emitter)); + assertDiagnostics(emitter, /* jsReady= */ false, liveContext, liveContext); + } + + /** + * Emit prefers the pending live context while the host/attached pointer still names the dying + * generation, without waiting for dying detach. + */ + @Test + public void emit_deliversToPendingLiveContext_whileHostStillOnDyingGeneration() { + when(reactHost.getCurrentReactContext()).thenReturn(staleContext); + + ReactNativeFirebaseEventEmitter emitter = ReactNativeFirebaseEventEmitter.getSharedInstance(); + emitter.attachReactContext(staleContext); + ShadowLooper.idleMainLooper(); + emitter.attachReactContext(liveContext); + ShadowLooper.idleMainLooper(); + + emitter.notifyJsReady(true); + emitter.addListener(EVENT); + ShadowLooper.idleMainLooper(); + + emitter.sendEvent(event(EVENT, eventBody)); + ShadowLooper.idleMainLooper(); + + verify(liveJsEmitter).emit(eq("rnfb_" + EVENT), eq(eventBody)); + verify(staleJsEmitter, never()).emit(anyString(), eq(eventBody)); + } + + /** + * Dying same-context re-attach (resume) while the host still names the dying generation must not + * wipe a different pending live replacement — otherwise dying detach later resets listeners for + * the rest of the session (#8374-class). + */ + @Test + public void attach_preservesPending_whenDyingSameContextReattachWhileHostLags() throws Exception { + when(reactHost.getCurrentReactContext()).thenReturn(staleContext); + + ReactNativeFirebaseEventEmitter emitter = ReactNativeFirebaseEventEmitter.getSharedInstance(); + emitter.attachReactContext(staleContext); + ShadowLooper.idleMainLooper(); + emitter.attachReactContext(liveContext); + ShadowLooper.idleMainLooper(); + + assertEquals(staleContext, attachedContext(emitter)); + assertEquals(liveContext, pendingContext(emitter)); + + emitter.notifyJsReady(true); + emitter.addListener(EVENT); + ShadowLooper.idleMainLooper(); + assertEquals(1, listenerCount(emitter, EVENT)); + + // Dying generation resume / re-attach while host still names it. + emitter.attachReactContext(staleContext); + ShadowLooper.idleMainLooper(); + + assertEquals( + "pending live replacement must survive dying same-context re-attach", + liveContext, + pendingContext(emitter)); + assertEquals(staleContext, attachedContext(emitter)); + assertEquals(staleContext, hostLagContext(emitter)); + assertEquals(1, listenerCount(emitter, EVENT)); + assertTrue(jsReady(emitter)); + + emitter.detachReactContext(staleContext); + ShadowLooper.idleMainLooper(); + assertEquals(liveContext, attachedContext(emitter)); + assertEquals(1, listenerCount(emitter, EVENT)); + assertTrue(jsReady(emitter)); + + emitter.sendEvent(event(EVENT, eventBody)); + ShadowLooper.idleMainLooper(); + verify(liveJsEmitter).emit(eq("rnfb_" + EVENT), eq(eventBody)); + verify(staleJsEmitter, never()).emit(anyString(), eq(eventBody)); + } + + /** + * Pending-only invalidate must cancel the pending slot rather than early-returning on the + * attached-identity check, and must restore the surviving attached generation's listener / + * jsReady snapshot taken when pending first entered — otherwise the dying JS (which already + * registered once) leaves the attached runtime deaf for the rest of the session. + */ + @Test + public void detach_restoresAttachedListeners_whenPendingOnlyCancelled() throws Exception { + ReactContext doomedPending = mock(ReactContext.class); + DeviceEventManagerModule.RCTDeviceEventEmitter doomedJsEmitter = + mock(DeviceEventManagerModule.RCTDeviceEventEmitter.class); + when(doomedPending.getApplicationContext()).thenReturn(application); + when(doomedPending.hasActiveReactInstance()).thenReturn(true); + when(doomedPending.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class)) + .thenReturn(doomedJsEmitter); + + when(reactHost.getCurrentReactContext()).thenReturn(staleContext); + + ReactNativeFirebaseEventEmitter emitter = ReactNativeFirebaseEventEmitter.getSharedInstance(); + emitter.attachReactContext(staleContext); + ShadowLooper.idleMainLooper(); + + // Attached generation registers before pending enters. + emitter.notifyJsReady(true); + emitter.addListener(EVENT); + ShadowLooper.idleMainLooper(); + assertEquals(1, listenerCount(emitter, EVENT)); + assertTrue(jsReady(emitter)); + + emitter.attachReactContext(doomedPending); + ShadowLooper.idleMainLooper(); + + assertEquals(doomedPending, pendingContext(emitter)); + assertEquals(staleContext, attachedContext(emitter)); + assertEquals( + "pending entry resets live map so events queue until pending JS re-registers", + 0, + listenerCount(emitter, EVENT)); + assertFalse(jsReady(emitter)); + + // Pending JS registers its own accounting (must be discarded on cancel, not kept). + emitter.notifyJsReady(true); + emitter.addListener(EVENT); + ShadowLooper.idleMainLooper(); + assertEquals(1, listenerCount(emitter, EVENT)); + + emitter.detachReactContext(doomedPending); + ShadowLooper.idleMainLooper(); + + assertEquals(null, pendingContext(emitter)); + assertEquals(null, hostLagContext(emitter)); + assertEquals(staleContext, attachedContext(emitter)); + assertEquals( + "pending-only cancel must restore attached generation listener accounting", + 1, + listenerCount(emitter, EVENT)); + assertTrue("pending-only cancel must restore attached generation jsReady", jsReady(emitter)); + + emitter.sendEvent(event(EVENT, eventBody)); + ShadowLooper.idleMainLooper(); + verify(staleJsEmitter).emit(eq("rnfb_" + EVENT), eq(eventBody)); + verify(doomedJsEmitter, never()).emit(anyString(), eq(eventBody)); + } + + /** + * Events raised while pending entry wiped attached listeners are queued. Pending-only cancel must + * restore accounting and flush the queue — attached JS will not re-register. + */ + @Test + public void detach_flushesQueuedEvents_whenPendingOnlyCancelledAfterQueueDuringWipe() + throws Exception { + ReactContext doomedPending = mock(ReactContext.class); + DeviceEventManagerModule.RCTDeviceEventEmitter doomedJsEmitter = + mock(DeviceEventManagerModule.RCTDeviceEventEmitter.class); + when(doomedPending.getApplicationContext()).thenReturn(application); + when(doomedPending.hasActiveReactInstance()).thenReturn(true); + when(doomedPending.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class)) + .thenReturn(doomedJsEmitter); + + when(reactHost.getCurrentReactContext()).thenReturn(staleContext); + + ReactNativeFirebaseEventEmitter emitter = ReactNativeFirebaseEventEmitter.getSharedInstance(); + emitter.attachReactContext(staleContext); + ShadowLooper.idleMainLooper(); + + emitter.notifyJsReady(true); + emitter.addListener(EVENT); + ShadowLooper.idleMainLooper(); + + emitter.attachReactContext(doomedPending); + ShadowLooper.idleMainLooper(); + assertEquals(0, listenerCount(emitter, EVENT)); + assertFalse(jsReady(emitter)); + + // Queued during the wiped pending window (messaging-style / #8374-class). + emitter.sendEvent(event(EVENT, eventBody)); + ShadowLooper.idleMainLooper(); + assertEquals(1, queuedCount(emitter)); + verify(staleJsEmitter, never()).emit(anyString(), eq(eventBody)); + verify(doomedJsEmitter, never()).emit(anyString(), eq(eventBody)); + + emitter.detachReactContext(doomedPending); + ShadowLooper.idleMainLooper(); + + assertEquals(0, queuedCount(emitter)); + verify(staleJsEmitter).emit(eq("rnfb_" + EVENT), eq(eventBody)); + verify(doomedJsEmitter, never()).emit(anyString(), eq(eventBody)); + } + + /** + * Same pending identity re-attach while the host still names the dying generation (async + * onHostResume / NativeRNFBTurboApp) must not resetListenerState — otherwise live JS + * registrations made after the first pending entry are wiped for the rest of the session + * (#8374-class). + */ + @Test + public void attach_preservesPendingListeners_whenSamePendingReattachWhileHostLags() + throws Exception { + when(reactHost.getCurrentReactContext()).thenReturn(staleContext); + + ReactNativeFirebaseEventEmitter emitter = ReactNativeFirebaseEventEmitter.getSharedInstance(); + emitter.attachReactContext(staleContext); + ShadowLooper.idleMainLooper(); + emitter.attachReactContext(liveContext); + ShadowLooper.idleMainLooper(); + + assertEquals(staleContext, attachedContext(emitter)); + assertEquals(liveContext, pendingContext(emitter)); + + emitter.notifyJsReady(true); + emitter.addListener(EVENT); + ShadowLooper.idleMainLooper(); + assertEquals(1, listenerCount(emitter, EVENT)); + assertTrue(jsReady(emitter)); + + // Same pending identity re-attach while host still lags (e.g. onHostResume). + emitter.attachReactContext(liveContext); + ShadowLooper.idleMainLooper(); + + assertEquals(liveContext, pendingContext(emitter)); + assertEquals(staleContext, attachedContext(emitter)); + assertEquals(staleContext, hostLagContext(emitter)); + assertEquals( + "same-pending re-attach must not wipe listeners registered after first pending entry", + 1, + listenerCount(emitter, EVENT)); + assertTrue( + "same-pending re-attach must not clear jsReady registered after first pending entry", + jsReady(emitter)); + + emitter.detachReactContext(staleContext); + ShadowLooper.idleMainLooper(); + assertEquals(liveContext, attachedContext(emitter)); + assertEquals(1, listenerCount(emitter, EVENT)); + assertTrue(jsReady(emitter)); + + emitter.sendEvent(event(EVENT, eventBody)); + ShadowLooper.idleMainLooper(); + verify(liveJsEmitter).emit(eq("rnfb_" + EVENT), eq(eventBody)); + verify(staleJsEmitter, never()).emit(anyString(), eq(eventBody)); + } + + /** + * Detach of a superseded (non-current) pending identity must not wipe listener / jsReady state + * owned by a newer live pending candidate. + */ + @Test + public void detach_doesNotClearListeners_whenSupersededPendingDetaches() throws Exception { + ReactContext middlePending = mock(ReactContext.class); + DeviceEventManagerModule.RCTDeviceEventEmitter middleJsEmitter = + mock(DeviceEventManagerModule.RCTDeviceEventEmitter.class); + when(middlePending.getApplicationContext()).thenReturn(application); + when(middlePending.hasActiveReactInstance()).thenReturn(true); + when(middlePending.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class)) + .thenReturn(middleJsEmitter); + + when(reactHost.getCurrentReactContext()).thenReturn(staleContext); + + ReactNativeFirebaseEventEmitter emitter = ReactNativeFirebaseEventEmitter.getSharedInstance(); + emitter.attachReactContext(staleContext); + ShadowLooper.idleMainLooper(); + emitter.attachReactContext(middlePending); + ShadowLooper.idleMainLooper(); + emitter.attachReactContext(liveContext); + ShadowLooper.idleMainLooper(); + + emitter.notifyJsReady(true); + emitter.addListener(EVENT); + ShadowLooper.idleMainLooper(); + assertEquals(liveContext, pendingContext(emitter)); + assertEquals(1, listenerCount(emitter, EVENT)); + + emitter.detachReactContext(middlePending); + ShadowLooper.idleMainLooper(); + + assertEquals(liveContext, pendingContext(emitter)); + assertEquals(1, listenerCount(emitter, EVENT)); + assertTrue(jsReady(emitter)); + } + + /** + * A third overlapping candidate while the host still names the dying attached generation is + * last-writer-wins: pending becomes the newest candidate and prior pending listener accounting is + * reset so the newest JS re-registers. + */ + @Test + public void attach_pendingLastWriterWins_whenThirdOverlappingCandidateArrives() throws Exception { + ReactContext middlePending = mock(ReactContext.class); + DeviceEventManagerModule.RCTDeviceEventEmitter middleJsEmitter = + mock(DeviceEventManagerModule.RCTDeviceEventEmitter.class); + when(middlePending.getApplicationContext()).thenReturn(application); + when(middlePending.hasActiveReactInstance()).thenReturn(true); + when(middlePending.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class)) + .thenReturn(middleJsEmitter); + + when(reactHost.getCurrentReactContext()).thenReturn(staleContext); + + ReactNativeFirebaseEventEmitter emitter = ReactNativeFirebaseEventEmitter.getSharedInstance(); + emitter.attachReactContext(staleContext); + ShadowLooper.idleMainLooper(); + emitter.attachReactContext(middlePending); + ShadowLooper.idleMainLooper(); + + emitter.notifyJsReady(true); + emitter.addListener(EVENT); + ShadowLooper.idleMainLooper(); + assertEquals(middlePending, pendingContext(emitter)); + assertEquals(1, listenerCount(emitter, EVENT)); + + // Third overlapping generation while host still on dying attached. + emitter.attachReactContext(liveContext); + ShadowLooper.idleMainLooper(); + + assertEquals(liveContext, pendingContext(emitter)); + assertEquals(staleContext, attachedContext(emitter)); + assertEquals(staleContext, hostLagContext(emitter)); + assertEquals( + "last-writer-wins pending must reset prior pending listener accounting", + 0, + listenerCount(emitter, EVENT)); + assertFalse(jsReady(emitter)); + + emitter.notifyJsReady(true); + emitter.addListener(EVENT); + ShadowLooper.idleMainLooper(); + + // Detach of superseded middle pending must not clear the newer pending. + emitter.detachReactContext(middlePending); + ShadowLooper.idleMainLooper(); + assertEquals(liveContext, pendingContext(emitter)); + + emitter.detachReactContext(staleContext); + ShadowLooper.idleMainLooper(); + assertEquals(liveContext, attachedContext(emitter)); + assertEquals(1, listenerCount(emitter, EVENT)); + + emitter.sendEvent(event(EVENT, eventBody)); + ShadowLooper.idleMainLooper(); + verify(liveJsEmitter).emit(eq("rnfb_" + EVENT), eq(eventBody)); + verify(middleJsEmitter, never()).emit(anyString(), eq(eventBody)); + verify(staleJsEmitter, never()).emit(anyString(), eq(eventBody)); + } + + /** + * When something is already attached and the host names a third context, an attach candidate that + * is neither host-current nor the previous attachment is ignored (fail-closed). + */ + @Test + public void attach_ignoresNonCurrentCandidate_whenHostAlreadyMovedToThirdContext() + throws Exception { + ReactContext thirdContext = mock(ReactContext.class); + when(thirdContext.getApplicationContext()).thenReturn(application); + when(thirdContext.hasActiveReactInstance()).thenReturn(true); + + when(reactHost.getCurrentReactContext()).thenReturn(staleContext); + ReactNativeFirebaseEventEmitter emitter = ReactNativeFirebaseEventEmitter.getSharedInstance(); + emitter.attachReactContext(staleContext); + ShadowLooper.idleMainLooper(); + + when(reactHost.getCurrentReactContext()).thenReturn(liveContext); + // Candidate is neither host current (live) nor previous attached (stale). + emitter.attachReactContext(thirdContext); + ShadowLooper.idleMainLooper(); + + assertEquals(staleContext, attachedContext(emitter)); + assertNull(pendingContext(emitter)); + } + + /** + * Detach of the sole attached generation with no pending replacement clears attachment, listener + * accounting, and convergence slots while intentionally retaining queued events. + */ + @Test + public void detach_clearsAttachedState_whenNoPendingReplacement() throws Exception { + when(reactHost.getCurrentReactContext()).thenReturn(liveContext); + ReactNativeFirebaseEventEmitter emitter = ReactNativeFirebaseEventEmitter.getSharedInstance(); + emitter.attachReactContext(liveContext); + ShadowLooper.idleMainLooper(); + emitter.notifyJsReady(true); + emitter.addListener(EVENT); + ShadowLooper.idleMainLooper(); + + emitter.detachReactContext(liveContext); + ShadowLooper.idleMainLooper(); + + assertNull(attachedContext(emitter)); + assertNull(pendingContext(emitter)); + assertNull(hostLagContext(emitter)); + assertEquals(0, listenerCount(emitter, EVENT)); + assertFalse(jsReady(emitter)); + } + + /** Repeated addListener for the same event increments the per-event count. */ + @Test + public void addListener_incrementsExistingEventCount() throws Exception { + when(reactHost.getCurrentReactContext()).thenReturn(liveContext); + ReactNativeFirebaseEventEmitter emitter = ReactNativeFirebaseEventEmitter.getSharedInstance(); + emitter.attachReactContext(liveContext); + ShadowLooper.idleMainLooper(); + + emitter.addListener(EVENT); + emitter.addListener(EVENT); + ShadowLooper.idleMainLooper(); + + assertEquals(2, listenerCount(emitter, EVENT)); + } + + /** removeListener(all=false) with count > 1 decrements rather than removing the key. */ + @Test + public void removeListener_decrementsWhenMultipleRemain() throws Exception { + when(reactHost.getCurrentReactContext()).thenReturn(liveContext); + ReactNativeFirebaseEventEmitter emitter = ReactNativeFirebaseEventEmitter.getSharedInstance(); + emitter.attachReactContext(liveContext); + ShadowLooper.idleMainLooper(); + + emitter.addListener(EVENT); + emitter.addListener(EVENT); + emitter.removeListener(EVENT, false); + ShadowLooper.idleMainLooper(); + + assertEquals(1, listenerCount(emitter, EVENT)); + } + + /** removeListener removes the event key when the last registration is dropped. */ + @Test + public void removeListener_removesKey_whenLastRegistrationDropped() throws Exception { + when(reactHost.getCurrentReactContext()).thenReturn(liveContext); + ReactNativeFirebaseEventEmitter emitter = ReactNativeFirebaseEventEmitter.getSharedInstance(); + emitter.attachReactContext(liveContext); + ShadowLooper.idleMainLooper(); + + emitter.addListener(EVENT); + emitter.removeListener(EVENT, false); + ShadowLooper.idleMainLooper(); + + assertEquals(0, listenerCount(emitter, EVENT)); + } + + /** removeListener(all=true) removes the key even when multiple registrations remain. */ + @Test + public void removeListener_removesKey_whenAllFlagTrue() throws Exception { + when(reactHost.getCurrentReactContext()).thenReturn(liveContext); + ReactNativeFirebaseEventEmitter emitter = ReactNativeFirebaseEventEmitter.getSharedInstance(); + emitter.attachReactContext(liveContext); + ShadowLooper.idleMainLooper(); + + emitter.addListener(EVENT); + emitter.addListener(EVENT); + emitter.removeListener(EVENT, true); + ShadowLooper.idleMainLooper(); + + assertEquals(0, listenerCount(emitter, EVENT)); + } + + /** + * Same-context re-attach while the host already names that context clears residual convergence + * slots (e.g. leftover hostLag) when there is no different pending replacement. + */ + @Test + public void attach_clearsConvergence_whenSameContextReattachAndHostCurrent() throws Exception { + when(reactHost.getCurrentReactContext()).thenReturn(liveContext); + ReactNativeFirebaseEventEmitter emitter = ReactNativeFirebaseEventEmitter.getSharedInstance(); + emitter.attachReactContext(liveContext); + ShadowLooper.idleMainLooper(); + + setWeakRefField(emitter, "hostLagReactContext", staleContext); + assertEquals(staleContext, hostLagContext(emitter)); + + emitter.attachReactContext(liveContext); + ShadowLooper.idleMainLooper(); + + assertNull(hostLagContext(emitter)); + assertNull(pendingContext(emitter)); + assertEquals(liveContext, attachedContext(emitter)); + } + + /** + * Events queued before a matching listener exists are drained by sendQueuedEvents once the + * listener is registered. + */ + @Test + public void sendQueuedEvents_drainsAndEmits_whenListenerRegistersAfterQueue() { + when(reactHost.getCurrentReactContext()).thenReturn(liveContext); + ReactNativeFirebaseEventEmitter emitter = ReactNativeFirebaseEventEmitter.getSharedInstance(); + emitter.attachReactContext(liveContext); + ShadowLooper.idleMainLooper(); + emitter.notifyJsReady(true); + ShadowLooper.idleMainLooper(); + + emitter.sendEvent(event(EVENT, eventBody)); + ShadowLooper.idleMainLooper(); + verify(liveJsEmitter, never()).emit(anyString(), eq(eventBody)); + + emitter.addListener(EVENT); + ShadowLooper.idleMainLooper(); + + verify(liveJsEmitter).emit(eq("rnfb_" + EVENT), eq(eventBody)); + } + + /** Emit aborts immediately when jsReady is still false. */ + @Test + public void emit_returnsFalse_whenJsReadyFalse() { + when(reactHost.getCurrentReactContext()).thenReturn(liveContext); + ReactNativeFirebaseEventEmitter emitter = ReactNativeFirebaseEventEmitter.getSharedInstance(); + emitter.attachReactContext(liveContext); + ShadowLooper.idleMainLooper(); + emitter.addListener(EVENT); + ShadowLooper.idleMainLooper(); + + emitter.sendEvent(event(EVENT, eventBody)); + ShadowLooper.idleMainLooper(); + + verify(liveJsEmitter, never()).emit(anyString(), eq(eventBody)); + } + + /** + * Emit falls back to the pending context hint when the attached WeakReference has already cleared + * (dying generation GC'd) while a pending replacement remains. + */ + @Test + public void emit_usesPendingHint_whenAttachedWeakReferenceCleared() throws Exception { + when(reactHost.getCurrentReactContext()).thenReturn(staleContext); + ReactNativeFirebaseEventEmitter emitter = ReactNativeFirebaseEventEmitter.getSharedInstance(); + emitter.attachReactContext(staleContext); + ShadowLooper.idleMainLooper(); + emitter.attachReactContext(liveContext); + ShadowLooper.idleMainLooper(); + + emitter.notifyJsReady(true); + emitter.addListener(EVENT); + ShadowLooper.idleMainLooper(); + + setWeakRefField(emitter, "attachedReactContext", null); + + emitter.sendEvent(event(EVENT, eventBody)); + ShadowLooper.idleMainLooper(); + + verify(liveJsEmitter).emit(eq("rnfb_" + EVENT), eq(eventBody)); + } + + /** + * If jsReady flips false between the unlocked host resolve and the second monitor entry, emit + * must abort rather than delivering. + */ + @Test + public void emit_returnsFalse_whenJsReadyClearedDuringHostResolve() throws Exception { + when(reactHost.getCurrentReactContext()).thenReturn(liveContext); + ReactNativeFirebaseEventEmitter emitter = ReactNativeFirebaseEventEmitter.getSharedInstance(); + emitter.attachReactContext(liveContext); + ShadowLooper.idleMainLooper(); + emitter.notifyJsReady(true); + emitter.addListener(EVENT); + ShadowLooper.idleMainLooper(); + + AtomicBoolean cleared = new AtomicBoolean(false); + when(reactHost.getCurrentReactContext()) + .thenAnswer( + invocation -> { + if (cleared.compareAndSet(false, true)) { + Field field = ReactNativeFirebaseEventEmitter.class.getDeclaredField("jsReady"); + field.setAccessible(true); + field.setBoolean(emitter, false); + } + return liveContext; + }); + + emitter.sendEvent(event(EVENT, eventBody)); + ShadowLooper.idleMainLooper(); + + verify(liveJsEmitter, never()).emit(anyString(), eq(eventBody)); + } + + /** Emit refuses delivery when the resolved context reports no active React instance. */ + @Test + public void emit_returnsFalse_whenResolvedContextHasNoActiveInstance() { + when(reactHost.getCurrentReactContext()).thenReturn(liveContext); + when(liveContext.hasActiveReactInstance()).thenReturn(false); + + ReactNativeFirebaseEventEmitter emitter = ReactNativeFirebaseEventEmitter.getSharedInstance(); + emitter.attachReactContext(liveContext); + ShadowLooper.idleMainLooper(); + emitter.notifyJsReady(true); + emitter.addListener(EVENT); + ShadowLooper.idleMainLooper(); + + emitter.sendEvent(event(EVENT, eventBody)); + ShadowLooper.idleMainLooper(); + + verify(liveJsEmitter, never()).emit(anyString(), eq(eventBody)); + } + + /** Emit catches DeviceEventEmitter failures and returns false rather than propagating. */ + @Test + public void emit_returnsFalse_whenDeviceEventEmitterThrows() { + when(reactHost.getCurrentReactContext()).thenReturn(liveContext); + doThrow(new RuntimeException("emit failed")) + .when(liveJsEmitter) + .emit(anyString(), eq(eventBody)); + + ReactNativeFirebaseEventEmitter emitter = ReactNativeFirebaseEventEmitter.getSharedInstance(); + emitter.attachReactContext(liveContext); + ShadowLooper.idleMainLooper(); + emitter.notifyJsReady(true); + emitter.addListener(EVENT); + ShadowLooper.idleMainLooper(); + + emitter.sendEvent(event(EVENT, eventBody)); + ShadowLooper.idleMainLooper(); + + verify(liveJsEmitter).emit(eq("rnfb_" + EVENT), eq(eventBody)); + } + + /** + * While a pending replacement owns listeners, an unrelated third host current must not receive + * events (that runtime has no RNFB JS subscribers). Emit prefers pending so delivery is not + * silently lost without re-queue. + */ + @Test + public void emit_deliversToPending_whenHostMovedToUnrelatedThirdContext() throws Exception { + ReactContext thirdContext = mock(ReactContext.class); + DeviceEventManagerModule.RCTDeviceEventEmitter thirdJsEmitter = + mock(DeviceEventManagerModule.RCTDeviceEventEmitter.class); + when(thirdContext.getApplicationContext()).thenReturn(application); + when(thirdContext.hasActiveReactInstance()).thenReturn(true); + when(thirdContext.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class)) + .thenReturn(thirdJsEmitter); + + when(reactHost.getCurrentReactContext()).thenReturn(staleContext); + ReactNativeFirebaseEventEmitter emitter = ReactNativeFirebaseEventEmitter.getSharedInstance(); + emitter.attachReactContext(staleContext); + ShadowLooper.idleMainLooper(); + emitter.attachReactContext(liveContext); + ShadowLooper.idleMainLooper(); + + emitter.notifyJsReady(true); + emitter.addListener(EVENT); + ShadowLooper.idleMainLooper(); + + when(reactHost.getCurrentReactContext()).thenReturn(thirdContext); + emitter.sendEvent(event(EVENT, eventBody)); + ShadowLooper.idleMainLooper(); + + verify(liveJsEmitter).emit(eq("rnfb_" + EVENT), eq(eventBody)); + verify(thirdJsEmitter, never()).emit(anyString(), eq(eventBody)); + verify(staleJsEmitter, never()).emit(anyString(), eq(eventBody)); + } + + /** + * After pending-only cancel following a host advance to an unrelated third context, restored + * listeners belong to the surviving attached generation. Emit must not prefer that unattached + * hostCurrent (silent delivery into a runtime with no RNFB JS subscribers). + */ + @Test + public void emit_deliversToRestoredAttached_afterPendingCancelWhenHostOnUnrelatedThird() + throws Exception { + ReactContext thirdContext = mock(ReactContext.class); + DeviceEventManagerModule.RCTDeviceEventEmitter thirdJsEmitter = + mock(DeviceEventManagerModule.RCTDeviceEventEmitter.class); + when(thirdContext.getApplicationContext()).thenReturn(application); + when(thirdContext.hasActiveReactInstance()).thenReturn(true); + when(thirdContext.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class)) + .thenReturn(thirdJsEmitter); + + when(reactHost.getCurrentReactContext()).thenReturn(staleContext); + ReactNativeFirebaseEventEmitter emitter = ReactNativeFirebaseEventEmitter.getSharedInstance(); + emitter.attachReactContext(staleContext); + ShadowLooper.idleMainLooper(); + + emitter.notifyJsReady(true); + emitter.addListener(EVENT); + ShadowLooper.idleMainLooper(); + + emitter.attachReactContext(liveContext); + ShadowLooper.idleMainLooper(); + assertEquals(liveContext, pendingContext(emitter)); + + when(reactHost.getCurrentReactContext()).thenReturn(thirdContext); + emitter.detachReactContext(liveContext); + ShadowLooper.idleMainLooper(); + + assertEquals(null, pendingContext(emitter)); + assertEquals(staleContext, attachedContext(emitter)); + assertEquals(1, listenerCount(emitter, EVENT)); + assertTrue(jsReady(emitter)); + + emitter.sendEvent(event(EVENT, eventBody)); + ShadowLooper.idleMainLooper(); + + verify(staleJsEmitter).emit(eq("rnfb_" + EVENT), eq(eventBody)); + verify(thirdJsEmitter, never()).emit(anyString(), eq(eventBody)); + verify(liveJsEmitter, never()).emit(anyString(), eq(eventBody)); + } + + /** + * A subsequent acceptAttachedContext of the host's live generation clears restored-attached emit + * affinity so delivery follows the new attached runtime. + */ + @Test + public void emit_prefersNewlyAttachedHost_afterRestoredAffinityClearedByAccept() + throws Exception { + ReactContext thirdContext = mock(ReactContext.class); + DeviceEventManagerModule.RCTDeviceEventEmitter thirdJsEmitter = + mock(DeviceEventManagerModule.RCTDeviceEventEmitter.class); + when(thirdContext.getApplicationContext()).thenReturn(application); + when(thirdContext.hasActiveReactInstance()).thenReturn(true); + when(thirdContext.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class)) + .thenReturn(thirdJsEmitter); + + when(reactHost.getCurrentReactContext()).thenReturn(staleContext); + ReactNativeFirebaseEventEmitter emitter = ReactNativeFirebaseEventEmitter.getSharedInstance(); + emitter.attachReactContext(staleContext); + ShadowLooper.idleMainLooper(); + + emitter.notifyJsReady(true); + emitter.addListener(EVENT); + ShadowLooper.idleMainLooper(); + + emitter.attachReactContext(liveContext); + ShadowLooper.idleMainLooper(); + + when(reactHost.getCurrentReactContext()).thenReturn(thirdContext); + emitter.detachReactContext(liveContext); + ShadowLooper.idleMainLooper(); + + // Host generation attaches for real — affinity must clear. + emitter.attachReactContext(thirdContext); + ShadowLooper.idleMainLooper(); + assertEquals(thirdContext, attachedContext(emitter)); + + emitter.notifyJsReady(true); + emitter.addListener(EVENT); + ShadowLooper.idleMainLooper(); + + emitter.sendEvent(event(EVENT, eventBody)); + ShadowLooper.idleMainLooper(); + + verify(thirdJsEmitter).emit(eq("rnfb_" + EVENT), eq(eventBody)); + verify(staleJsEmitter, never()).emit(anyString(), eq(eventBody)); + } + + /** + * With no pending slot and a null host current, emit delivers into the still-attached context. + */ + @Test + public void emit_deliversToAttached_whenHostCurrentNullAndNoPending() { + when(reactHost.getCurrentReactContext()).thenReturn(liveContext); + ReactNativeFirebaseEventEmitter emitter = ReactNativeFirebaseEventEmitter.getSharedInstance(); + emitter.attachReactContext(liveContext); + ShadowLooper.idleMainLooper(); + emitter.notifyJsReady(true); + emitter.addListener(EVENT); + ShadowLooper.idleMainLooper(); + + when(reactHost.getCurrentReactContext()).thenReturn(null); + emitter.sendEvent(event(EVENT, eventBody)); + ShadowLooper.idleMainLooper(); + + verify(liveJsEmitter).emit(eq("rnfb_" + EVENT), eq(eventBody)); + } + + /** + * A posted tryConverge that runs after the pending slot was cancelled is a no-op (pendingHint + * null). + */ + @Test + public void tryConverge_noOps_whenPendingCancelledBeforePostedRunnableRuns() throws Exception { + when(reactHost.getCurrentReactContext()).thenReturn(staleContext); + ReactNativeFirebaseEventEmitter emitter = ReactNativeFirebaseEventEmitter.getSharedInstance(); + emitter.attachReactContext(staleContext); + ShadowLooper.idleMainLooper(); + + // Enters pending and posts tryConverge; do not idle yet. + emitter.attachReactContext(liveContext); + assertEquals(liveContext, pendingContext(emitter)); + + // Cancel pending before the posted converge runs. + emitter.detachReactContext(liveContext); + assertNull(pendingContext(emitter)); + + ShadowLooper.idleMainLooper(); + assertEquals(staleContext, attachedContext(emitter)); + } + + /** + * Host catch-up between posting tryConverge and running it promotes pending without a second + * attach call. + */ + @Test + public void tryConverge_promotesPending_whenHostCatchesUpBeforePostedRunnable() throws Exception { + when(reactHost.getCurrentReactContext()).thenReturn(staleContext); + ReactNativeFirebaseEventEmitter emitter = ReactNativeFirebaseEventEmitter.getSharedInstance(); + emitter.attachReactContext(staleContext); + ShadowLooper.idleMainLooper(); + + emitter.attachReactContext(liveContext); + assertEquals(liveContext, pendingContext(emitter)); + assertEquals(staleContext, attachedContext(emitter)); + + when(reactHost.getCurrentReactContext()).thenReturn(liveContext); + ShadowLooper.idleMainLooper(); + + assertEquals(liveContext, attachedContext(emitter)); + assertNull(pendingContext(emitter)); + assertNull(hostLagContext(emitter)); + } + + /** + * When attached already equals pending at converge time, clear convergence state without + * re-accepting attachment. + */ + @Test + public void tryConverge_clearsStateOnly_whenAttachedAlreadyEqualsPending() throws Exception { + when(reactHost.getCurrentReactContext()).thenReturn(liveContext); + ReactNativeFirebaseEventEmitter emitter = ReactNativeFirebaseEventEmitter.getSharedInstance(); + emitter.attachReactContext(liveContext); + ShadowLooper.idleMainLooper(); + + setWeakRefField(emitter, "pendingReactContext", liveContext); + setWeakRefField(emitter, "hostLagReactContext", staleContext); + + Method converge = + ReactNativeFirebaseEventEmitter.class.getDeclaredMethod( + "tryConvergePendingReactContextLocked", ReactContext.class); + converge.setAccessible(true); + boolean changed = (boolean) converge.invoke(emitter, liveContext); + + assertFalse(changed); + assertNull(pendingContext(emitter)); + assertNull(hostLagContext(emitter)); + assertEquals(liveContext, attachedContext(emitter)); + } + + /** Brownfield hosts that are not {@link ReactApplication} resolve host current as null. */ + @Test + public void hostResolution_returnsNull_whenApplicationContextIsNotReactApplication() + throws Exception { + Application plainApp = mock(Application.class); + when(plainApp.getApplicationContext()).thenReturn(plainApp); + ReactContext brownfieldContext = mock(ReactContext.class); + when(brownfieldContext.getApplicationContext()).thenReturn(plainApp); + + Method resolve = + ReactNativeFirebaseEventEmitter.class.getDeclaredMethod( + "getCurrentReactContextFromHost", ReactContext.class); + resolve.setAccessible(true); + assertNull(resolve.invoke(null, brownfieldContext)); + } + + /** ReactHost getter failures are swallowed; resolution may still fall through. */ + @Test + public void hostResolution_swallowsReactHostRuntimeException() throws Exception { + Application reactApp = + mock( + Application.class, + org.mockito.Mockito.withSettings().extraInterfaces(ReactApplication.class)); + ReactApplication asReactApplication = (ReactApplication) reactApp; + when(reactApp.getApplicationContext()).thenReturn(reactApp); + when(asReactApplication.getReactHost()).thenThrow(new RuntimeException("reactHost broken")); + when(asReactApplication.getReactNativeHost()) + .thenThrow(new RuntimeException("bridgeless-only test host")); + + ReactContext hint = mock(ReactContext.class); + when(hint.getApplicationContext()).thenReturn(reactApp); + + Method resolve = + ReactNativeFirebaseEventEmitter.class.getDeclaredMethod( + "getCurrentReactContextFromHost", ReactContext.class); + resolve.setAccessible(true); + assertNull(resolve.invoke(null, hint)); + } + + /** When ReactHost is absent, ReactNativeHost failures are logged and null current is returned. */ + @Test + public void hostResolution_swallowsReactNativeHostFailure_whenReactHostAbsent() throws Exception { + application.reactHost = null; + ReactNativeHost rnHost = mock(ReactNativeHost.class); + application.reactNativeHost = rnHost; + when(rnHost.hasInstance()).thenThrow(new RuntimeException("native host broken")); + + Method resolve = + ReactNativeFirebaseEventEmitter.class.getDeclaredMethod( + "getCurrentReactContextFromHost", ReactContext.class); + resolve.setAccessible(true); + assertNull(resolve.invoke(null, liveContext)); + } + + /** + * When ReactHost is present but reports a null current and ReactNativeHost has no instance, + * resolution returns the null ReactHost current (hybrid fallthrough with nothing on the bridge). + */ + @Test + public void hostResolution_returnsNullFromReactHost_whenNativeHostHasNoInstance() + throws Exception { + when(reactHost.getCurrentReactContext()).thenReturn(null); + ReactNativeHost rnHost = mock(ReactNativeHost.class); + application.reactNativeHost = rnHost; + when(rnHost.hasInstance()).thenReturn(false); + + Method resolve = + ReactNativeFirebaseEventEmitter.class.getDeclaredMethod( + "getCurrentReactContextFromHost", ReactContext.class); + resolve.setAccessible(true); + assertNull(resolve.invoke(null, liveContext)); + } + + /** + * When ReactHost is present but reports a null current, a subsequent ReactNativeHost failure must + * still return the null ReactHost current. + */ + @Test + public void hostResolution_returnsNullFromReactHost_whenNativeHostFailsAfterNullCurrent() + throws Exception { + when(reactHost.getCurrentReactContext()).thenReturn(null); + ReactNativeHost rnHost = mock(ReactNativeHost.class); + application.reactNativeHost = rnHost; + when(rnHost.hasInstance()).thenThrow(new RuntimeException("native host broken")); + + Method resolve = + ReactNativeFirebaseEventEmitter.class.getDeclaredMethod( + "getCurrentReactContextFromHost", ReactContext.class); + resolve.setAccessible(true); + assertNull(resolve.invoke(null, liveContext)); + } + + /** + * Fallback {@link ReactNativeFirebaseApp#getApplicationContext()} path when the context hint is + * null still participates in host resolution (and brownfield null when unset). + */ + @Test + public void hostResolution_usesReactNativeFirebaseAppFallback_whenContextHintNull() + throws Exception { + Context previous = ReactNativeFirebaseApp.getApplicationContext(); + try { + ReactNativeFirebaseApp.setApplicationContext(null); + Method resolve = + ReactNativeFirebaseEventEmitter.class.getDeclaredMethod( + "getCurrentReactContextFromHost", ReactContext.class); + resolve.setAccessible(true); + assertNull(resolve.invoke(null, new Object[] {null})); + } finally { + ReactNativeFirebaseApp.setApplicationContext(previous); + } + } + + /** + * Same-context re-attach while the host names a different generation must not clear convergence + * (dying hostLag with a different pending must keep waiting). + */ + @Test + public void attach_doesNotClearConvergence_whenSameContextReattachAndHostCurrentDiffers() + throws Exception { + when(reactHost.getCurrentReactContext()).thenReturn(staleContext); + ReactNativeFirebaseEventEmitter emitter = ReactNativeFirebaseEventEmitter.getSharedInstance(); + emitter.attachReactContext(staleContext); + ShadowLooper.idleMainLooper(); + emitter.attachReactContext(liveContext); + ShadowLooper.idleMainLooper(); + + assertEquals(liveContext, pendingContext(emitter)); + assertEquals(staleContext, hostLagContext(emitter)); + + // Host moved onto live, but re-attach of dying stale must not clear pending. + when(reactHost.getCurrentReactContext()).thenReturn(liveContext); + emitter.attachReactContext(staleContext); + ShadowLooper.idleMainLooper(); + + assertEquals(liveContext, pendingContext(emitter)); + assertEquals(staleContext, attachedContext(emitter)); + assertEquals(staleContext, hostLagContext(emitter)); + } + + /** + * Same-context re-attach of the pending identity while the host has caught up clears residual + * convergence (pending == reactContext arm of the compound guard). + */ + @Test + public void attach_clearsConvergence_whenSamePendingReattachAndHostCurrent() throws Exception { + when(reactHost.getCurrentReactContext()).thenReturn(staleContext); + ReactNativeFirebaseEventEmitter emitter = ReactNativeFirebaseEventEmitter.getSharedInstance(); + emitter.attachReactContext(staleContext); + ShadowLooper.idleMainLooper(); + emitter.attachReactContext(liveContext); + ShadowLooper.idleMainLooper(); + + // Promote pending to attached while leaving pending slot set (host catch-up path normally + // clears; force the same-context re-attach compound pending == reactContext). + setWeakRefField(emitter, "attachedReactContext", liveContext); + setWeakRefField(emitter, "pendingReactContext", liveContext); + setWeakRefField(emitter, "hostLagReactContext", staleContext); + when(reactHost.getCurrentReactContext()).thenReturn(liveContext); + + emitter.attachReactContext(liveContext); + ShadowLooper.idleMainLooper(); + + assertNull(pendingContext(emitter)); + assertNull(hostLagContext(emitter)); + assertEquals(liveContext, attachedContext(emitter)); + } + + /** removeListener is a no-op when the event was never registered. */ + @Test + public void removeListener_isNoOp_whenEventNeverRegistered() throws Exception { + when(reactHost.getCurrentReactContext()).thenReturn(liveContext); + ReactNativeFirebaseEventEmitter emitter = ReactNativeFirebaseEventEmitter.getSharedInstance(); + emitter.attachReactContext(liveContext); + ShadowLooper.idleMainLooper(); + + emitter.removeListener(EVENT, false); + ShadowLooper.idleMainLooper(); + + assertEquals(0, listenerCount(emitter, EVENT)); + } + + /** + * sendQueuedEvents leaves queued events whose names have no current listener (does not remove + * them while draining matching ones). + */ + @Test + public void sendQueuedEvents_leavesUnmatchedQueuedEvents() throws Exception { + when(reactHost.getCurrentReactContext()).thenReturn(liveContext); + ReactNativeFirebaseEventEmitter emitter = ReactNativeFirebaseEventEmitter.getSharedInstance(); + emitter.attachReactContext(liveContext); + ShadowLooper.idleMainLooper(); + emitter.notifyJsReady(true); + ShadowLooper.idleMainLooper(); + + WritableMap otherBody = mock(WritableMap.class); + emitter.sendEvent(event(EVENT, eventBody)); + emitter.sendEvent(event("auth_id_token_changed", otherBody)); + ShadowLooper.idleMainLooper(); + + emitter.addListener(EVENT); + ShadowLooper.idleMainLooper(); + + verify(liveJsEmitter).emit(eq("rnfb_" + EVENT), eq(eventBody)); + verify(liveJsEmitter, never()).emit(eq("rnfb_auth_id_token_changed"), eq(otherBody)); + + Field queued = ReactNativeFirebaseEventEmitter.class.getDeclaredField("queuedEvents"); + queued.setAccessible(true); + @SuppressWarnings("unchecked") + java.util.List remaining = (java.util.List) queued.get(emitter); + assertEquals(1, remaining.size()); + assertEquals("auth_id_token_changed", remaining.get(0).getEventName()); + } + + /** + * Emit returns false (and re-queues) when resolveEmitContext yields null — no attached, no + * pending, and a null host current. + */ + @Test + public void emit_returnsFalse_whenEmitContextNull() throws Exception { + when(reactHost.getCurrentReactContext()).thenReturn(null); + ReactNativeFirebaseEventEmitter emitter = ReactNativeFirebaseEventEmitter.getSharedInstance(); + emitter.notifyJsReady(true); + emitter.addListener(EVENT); + ShadowLooper.idleMainLooper(); + + emitter.sendEvent(event(EVENT, eventBody)); + ShadowLooper.idleMainLooper(); + + verify(liveJsEmitter, never()).emit(anyString(), eq(eventBody)); + verify(staleJsEmitter, never()).emit(anyString(), eq(eventBody)); + + Field queued = ReactNativeFirebaseEventEmitter.class.getDeclaredField("queuedEvents"); + queued.setAccessible(true); + @SuppressWarnings("unchecked") + java.util.List remaining = (java.util.List) queued.get(emitter); + assertEquals(1, remaining.size()); + } + + /** With no pending and a null attached pointer, emit prefers the non-null host current. */ + @Test + public void emit_deliversToHostCurrent_whenAttachedNullAndNoPending() throws Exception { + when(reactHost.getCurrentReactContext()).thenReturn(liveContext); + ReactNativeFirebaseEventEmitter emitter = ReactNativeFirebaseEventEmitter.getSharedInstance(); + emitter.attachReactContext(liveContext); + ShadowLooper.idleMainLooper(); + emitter.notifyJsReady(true); + emitter.addListener(EVENT); + ShadowLooper.idleMainLooper(); + + // Clear attached after host can still resolve via FirebaseApp fallback + ReactHost. + Context previous = ReactNativeFirebaseApp.getApplicationContext(); + try { + ReactNativeFirebaseApp.setApplicationContext(application); + setWeakRefField(emitter, "attachedReactContext", null); + setWeakRefField(emitter, "pendingReactContext", null); + + emitter.sendEvent(event(EVENT, eventBody)); + ShadowLooper.idleMainLooper(); + + verify(liveJsEmitter).emit(eq("rnfb_" + EVENT), eq(eventBody)); + } finally { + ReactNativeFirebaseApp.setApplicationContext(previous); + } + } + + /** + * With no pending, a lagging host current that matches hostLag prefers the live attached + * generation over that dying host ref. + */ + @Test + public void emit_prefersAttached_whenHostLagMatchesHostCurrentAndNoPending() throws Exception { + when(reactHost.getCurrentReactContext()).thenReturn(liveContext); + ReactNativeFirebaseEventEmitter emitter = ReactNativeFirebaseEventEmitter.getSharedInstance(); + emitter.attachReactContext(liveContext); + ShadowLooper.idleMainLooper(); + emitter.notifyJsReady(true); + emitter.addListener(EVENT); + ShadowLooper.idleMainLooper(); + + setWeakRefField(emitter, "hostLagReactContext", staleContext); + when(reactHost.getCurrentReactContext()).thenReturn(staleContext); + + emitter.sendEvent(event(EVENT, eventBody)); + ShadowLooper.idleMainLooper(); + + verify(liveJsEmitter).emit(eq("rnfb_" + EVENT), eq(eventBody)); + verify(staleJsEmitter, never()).emit(anyString(), eq(eventBody)); + } + + /** + * Pending-only cancel with a cleared snapshot falls back to resetListenerState (defensive path if + * snapshot accounting was discarded while pending still lived). + */ + @Test + public void detach_resetsListeners_whenPendingOnlyCancelledWithoutSnapshot() throws Exception { + when(reactHost.getCurrentReactContext()).thenReturn(staleContext); + ReactNativeFirebaseEventEmitter emitter = ReactNativeFirebaseEventEmitter.getSharedInstance(); + emitter.attachReactContext(staleContext); + ShadowLooper.idleMainLooper(); + emitter.attachReactContext(liveContext); + ShadowLooper.idleMainLooper(); + + emitter.notifyJsReady(true); + emitter.addListener(EVENT); + ShadowLooper.idleMainLooper(); + + Field snapshot = + ReactNativeFirebaseEventEmitter.class.getDeclaredField("attachedListenersSnapshot"); + snapshot.setAccessible(true); + snapshot.set(emitter, null); + + emitter.detachReactContext(liveContext); + ShadowLooper.idleMainLooper(); + + assertNull(pendingContext(emitter)); + assertEquals(staleContext, attachedContext(emitter)); + assertEquals(0, listenerCount(emitter, EVENT)); + assertFalse(jsReady(emitter)); + } + + /** + * Last-writer-wins pending replace reuses the first attached snapshot so a later pending-only + * cancel still restores the original attached generation. + */ + @Test + public void detach_restoresOriginalAttachedSnapshot_afterLastWriterWinsPendingReplace() + throws Exception { + ReactContext middlePending = mock(ReactContext.class); + when(middlePending.getApplicationContext()).thenReturn(application); + when(middlePending.hasActiveReactInstance()).thenReturn(true); + + when(reactHost.getCurrentReactContext()).thenReturn(staleContext); + ReactNativeFirebaseEventEmitter emitter = ReactNativeFirebaseEventEmitter.getSharedInstance(); + emitter.attachReactContext(staleContext); + ShadowLooper.idleMainLooper(); + + emitter.notifyJsReady(true); + emitter.addListener(EVENT); + ShadowLooper.idleMainLooper(); + + emitter.attachReactContext(middlePending); + ShadowLooper.idleMainLooper(); + emitter.attachReactContext(liveContext); + ShadowLooper.idleMainLooper(); + + assertEquals(liveContext, pendingContext(emitter)); + assertEquals(0, listenerCount(emitter, EVENT)); + + emitter.detachReactContext(liveContext); + ShadowLooper.idleMainLooper(); + + assertEquals(staleContext, attachedContext(emitter)); + assertEquals(1, listenerCount(emitter, EVENT)); + assertTrue(jsReady(emitter)); + } + + /** Detach of attached when pending identity equals attached clears attachment (no promote). */ + @Test + public void detach_clearsAttached_whenPendingIdentityEqualsAttached() throws Exception { + when(reactHost.getCurrentReactContext()).thenReturn(liveContext); + ReactNativeFirebaseEventEmitter emitter = ReactNativeFirebaseEventEmitter.getSharedInstance(); + emitter.attachReactContext(liveContext); + ShadowLooper.idleMainLooper(); + emitter.notifyJsReady(true); + emitter.addListener(EVENT); + ShadowLooper.idleMainLooper(); + + setWeakRefField(emitter, "pendingReactContext", liveContext); + + emitter.detachReactContext(liveContext); + ShadowLooper.idleMainLooper(); + + assertNull(attachedContext(emitter)); + assertNull(pendingContext(emitter)); + assertEquals(0, listenerCount(emitter, EVENT)); + } + + /** + * getListenersMap prefers the pending context as the host-resolution hint while a pending + * candidate exists. + */ + @Test + public void getListenersMap_usesPendingHint_whenPendingExists() throws Exception { + when(reactHost.getCurrentReactContext()).thenReturn(staleContext); + ReactNativeFirebaseEventEmitter emitter = ReactNativeFirebaseEventEmitter.getSharedInstance(); + emitter.attachReactContext(staleContext); + ShadowLooper.idleMainLooper(); + emitter.attachReactContext(liveContext); + ShadowLooper.idleMainLooper(); + + assertEquals(liveContext, pendingContext(emitter)); + + WritableMap writableMap = mock(WritableMap.class); + WritableMap eventsMap = mock(WritableMap.class); + try (MockedStatic arguments = mockStatic(Arguments.class)) { + arguments.when(Arguments::createMap).thenReturn(writableMap, eventsMap); + emitter.getListenersMap(); + verify(writableMap).putInt("attachedContextHash", System.identityHashCode(staleContext)); + // Host still names dying attached; current hash follows host, not pending hint alone. + verify(writableMap).putInt("currentContextHash", System.identityHashCode(staleContext)); + } + } + + /** + * With no pending, a non-null hostLag that does not match host current does not divert emit away + * from the host-preferred path (hostLag != hostCurrent arm). + */ + @Test + public void emit_prefersHostCurrent_whenHostLagSetButDoesNotMatchHost() throws Exception { + when(reactHost.getCurrentReactContext()).thenReturn(liveContext); + ReactNativeFirebaseEventEmitter emitter = ReactNativeFirebaseEventEmitter.getSharedInstance(); + emitter.attachReactContext(staleContext); + ShadowLooper.idleMainLooper(); + emitter.notifyJsReady(true); + emitter.addListener(EVENT); + ShadowLooper.idleMainLooper(); + + setWeakRefField(emitter, "hostLagReactContext", staleContext); + when(reactHost.getCurrentReactContext()).thenReturn(liveContext); + + emitter.sendEvent(event(EVENT, eventBody)); + ShadowLooper.idleMainLooper(); + + verify(liveJsEmitter).emit(eq("rnfb_" + EVENT), eq(eventBody)); + verify(staleJsEmitter, never()).emit(anyString(), eq(eventBody)); + } + + /** + * Hybrid fallthrough: ReactHost reports null current and getReactNativeHost returns null — the + * null ReactHost current stands. + */ + @Test + public void hostResolution_returnsNull_whenReactNativeHostNullAfterNullReactHostCurrent() + throws Exception { + Application reactApp = + mock( + Application.class, + org.mockito.Mockito.withSettings().extraInterfaces(ReactApplication.class)); + ReactApplication asReactApplication = (ReactApplication) reactApp; + when(reactApp.getApplicationContext()).thenReturn(reactApp); + ReactHost host = mock(ReactHost.class); + when(asReactApplication.getReactHost()).thenReturn(host); + when(host.getCurrentReactContext()).thenReturn(null); + when(asReactApplication.getReactNativeHost()).thenReturn(null); + + ReactContext hint = mock(ReactContext.class); + when(hint.getApplicationContext()).thenReturn(reactApp); + + Method resolve = + ReactNativeFirebaseEventEmitter.class.getDeclaredMethod( + "getCurrentReactContextFromHost", ReactContext.class); + resolve.setAccessible(true); + assertNull(resolve.invoke(null, hint)); + } + + /** + * acceptAttachedContext is a no-op when the candidate is already the attached identity (guards + * the inner identity check; production callers only invoke it on a change). + */ + @Test + public void acceptAttachedContext_noOps_whenCandidateAlreadyAttached() throws Exception { + when(reactHost.getCurrentReactContext()).thenReturn(liveContext); + ReactNativeFirebaseEventEmitter emitter = ReactNativeFirebaseEventEmitter.getSharedInstance(); + emitter.attachReactContext(liveContext); + ShadowLooper.idleMainLooper(); + emitter.notifyJsReady(true); + emitter.addListener(EVENT); + ShadowLooper.idleMainLooper(); + + Method accept = + ReactNativeFirebaseEventEmitter.class.getDeclaredMethod( + "acceptAttachedContext", ReactContext.class, boolean.class); + accept.setAccessible(true); + accept.invoke(emitter, liveContext, true); + + assertEquals(liveContext, attachedContext(emitter)); + assertEquals(1, listenerCount(emitter, EVENT)); + assertTrue(jsReady(emitter)); + } + + /** + * Diagnostic map exposes jsReady and context identity hashes used for on-device diagnosis. {@link + * Arguments#createMap()} is mocked so the JVM test does not need native SoLoader. + */ + @Test + public void getListenersMap_exposesJsReadyAndContextIdentityHashes() { + when(reactHost.getCurrentReactContext()).thenReturn(liveContext); + + ReactNativeFirebaseEventEmitter emitter = ReactNativeFirebaseEventEmitter.getSharedInstance(); + emitter.attachReactContext(liveContext); + ShadowLooper.idleMainLooper(); + emitter.notifyJsReady(true); + emitter.addListener(EVENT); + ShadowLooper.idleMainLooper(); + + WritableMap writableMap = mock(WritableMap.class); + WritableMap eventsMap = mock(WritableMap.class); + + try (MockedStatic arguments = mockStatic(Arguments.class)) { + arguments.when(Arguments::createMap).thenReturn(writableMap, eventsMap); + + WritableMap result = emitter.getListenersMap(); + assertEquals(writableMap, result); + + verify(writableMap).putInt("listeners", 1); + verify(writableMap).putInt(eq("queued"), eq(0)); + verify(writableMap).putBoolean("jsReady", true); + verify(writableMap).putInt("attachedContextHash", System.identityHashCode(liveContext)); + verify(writableMap).putInt("currentContextHash", System.identityHashCode(liveContext)); + verify(eventsMap).putInt(EVENT, 1); + verify(writableMap).putMap("events", eventsMap); + } + } + + private static NativeEvent event(String name, WritableMap body) { + return new NativeEvent() { + @Override + public String getEventName() { + return name; + } + + @Override + public WritableMap getEventBody() { + return body; + } + + @Override + public String getFirebaseAppName() { + return "[DEFAULT]"; + } + }; + } + + private static void resetSharedInstance() throws Exception { + Constructor ctor = + ReactNativeFirebaseEventEmitter.class.getDeclaredConstructor(); + ctor.setAccessible(true); + ReactNativeFirebaseEventEmitter fresh = ctor.newInstance(); + + Field shared = ReactNativeFirebaseEventEmitter.class.getDeclaredField("sharedInstance"); + shared.setAccessible(true); + shared.set(null, fresh); + } + + @SuppressWarnings("unchecked") + private static int listenerCount(ReactNativeFirebaseEventEmitter emitter, String eventName) + throws Exception { + Field field = ReactNativeFirebaseEventEmitter.class.getDeclaredField("jsListeners"); + field.setAccessible(true); + HashMap listeners = (HashMap) field.get(emitter); + Integer count = listeners.get(eventName); + return count == null ? 0 : count; + } + + @SuppressWarnings("unchecked") + private static int queuedCount(ReactNativeFirebaseEventEmitter emitter) throws Exception { + Field field = ReactNativeFirebaseEventEmitter.class.getDeclaredField("queuedEvents"); + field.setAccessible(true); + return ((java.util.List) field.get(emitter)).size(); + } + + private static boolean jsReady(ReactNativeFirebaseEventEmitter emitter) throws Exception { + Field field = ReactNativeFirebaseEventEmitter.class.getDeclaredField("jsReady"); + field.setAccessible(true); + return field.getBoolean(emitter); + } + + @SuppressWarnings("unchecked") + private static ReactContext attachedContext(ReactNativeFirebaseEventEmitter emitter) + throws Exception { + Field field = ReactNativeFirebaseEventEmitter.class.getDeclaredField("attachedReactContext"); + field.setAccessible(true); + WeakReference ref = (WeakReference) field.get(emitter); + return ref.get(); + } + + @SuppressWarnings("unchecked") + private static ReactContext pendingContext(ReactNativeFirebaseEventEmitter emitter) + throws Exception { + Field field = ReactNativeFirebaseEventEmitter.class.getDeclaredField("pendingReactContext"); + field.setAccessible(true); + WeakReference ref = (WeakReference) field.get(emitter); + return ref.get(); + } + + @SuppressWarnings("unchecked") + private static ReactContext hostLagContext(ReactNativeFirebaseEventEmitter emitter) + throws Exception { + Field field = ReactNativeFirebaseEventEmitter.class.getDeclaredField("hostLagReactContext"); + field.setAccessible(true); + WeakReference ref = (WeakReference) field.get(emitter); + return ref.get(); + } + + private static void setWeakRefField( + ReactNativeFirebaseEventEmitter emitter, String fieldName, @Nullable ReactContext value) + throws Exception { + Field field = ReactNativeFirebaseEventEmitter.class.getDeclaredField(fieldName); + field.setAccessible(true); + field.set(emitter, new WeakReference<>(value)); + } + + /** + * Asserts the same diagnostic identities {@link + * ReactNativeFirebaseEventEmitter#getListenersMap()} exposes (jsReady / attached / host current + * hashes) without calling {@code Arguments.createMap()}, which requires native SoLoader. + */ + private static void assertDiagnostics( + ReactNativeFirebaseEventEmitter emitter, + boolean expectedJsReady, + @Nullable ReactContext expectedAttached, + @Nullable ReactContext expectedCurrent) + throws Exception { + assertEquals(expectedJsReady, jsReady(emitter)); + assertEquals(expectedAttached, attachedContext(emitter)); + + Method resolve = + ReactNativeFirebaseEventEmitter.class.getDeclaredMethod( + "getCurrentReactContextFromHost", ReactContext.class); + resolve.setAccessible(true); + ReactContext current = (ReactContext) resolve.invoke(null, expectedAttached); + assertEquals(expectedCurrent, current); + } +} diff --git a/tests/android/app/jacoco.gradle b/tests/android/app/jacoco.gradle index eac18cb7f8..ea23fe1a66 100644 --- a/tests/android/app/jacoco.gradle +++ b/tests/android/app/jacoco.gradle @@ -109,15 +109,34 @@ def configureJacocoReportTask(JacocoReport reportTask) { configureRnfbJacocoSourcesAndClasses(reportTask) } +// Library JVM .exec files come from :react-native-firebase_*:testDebugUnitTest +// (Jacoco plugin → build/jacoco/*.exec). Do not dependOn :app:testDebugUnitTest — +// that task is hollow and produces no RNFB coverage. See tests/android/build.gradle +// (enableAndroidTestCoverage for e2e .ec; Jacoco plugin for unit .exec). +def rnfbDebugUnitTestTaskPaths() { + return rootProject.subprojects + .findAll { it.name.contains('react-native-firebase') } + .collect { "${it.path}:testDebugUnitTest" } +} + +// Allowlisted via yarn tests:android:unit — all RNFB module JVM unit tests. +task rnfbDebugUnitTests { + group = 'verification' + description = 'Run debug JVM unit tests for all react-native-firebase Android library modules' + dependsOn { rnfbDebugUnitTestTaskPaths() } +} + // Unit + e2e (Detox) merged report. E2e coverage is pulled to build/output/coverage/*.ec -// after Detox; unit tests produce *.exec under each module's build/ when added. -task jacocoTestReport(type: JacocoReport, dependsOn: ['testDebugUnitTest']) { +// after Detox; unit tests produce *.exec under each module's build/. +task jacocoTestReport(type: JacocoReport) { + dependsOn { rnfbDebugUnitTestTaskPaths() } configureJacocoReportTask(it) executionData.from = rnfbJacocoExecutionData(['**/*.exec', '**/*.ec']) } -// Unit-test only report (for when android unit tests are added to RNFB modules or the app). -task jacocoUnitTestReport(type: JacocoReport, dependsOn: ['testDebugUnitTest']) { +// Unit-test only report (*.exec from RNFB library modules). +task jacocoUnitTestReport(type: JacocoReport) { + dependsOn { rnfbDebugUnitTestTaskPaths() } configureJacocoReportTask(it) executionData.from = rnfbJacocoExecutionData(['**/*.exec']) } diff --git a/tests/android/build.gradle b/tests/android/build.gradle index c11585b34f..ada4052eec 100644 --- a/tests/android/build.gradle +++ b/tests/android/build.gradle @@ -72,9 +72,25 @@ subprojects { // Configure testing settings - if set in module build.gradle files they affect library consumers if (project.name.contains('react-native-firebase') && project.hasProperty('android')) { + // Jacoco plugin for JVM unit .exec (AGP enableUnitTestCoverage on libraries yields + // exec files without product-class probes — see jacoco#1265). Keep androidTest + // coverage for Detox on-device .ec instrumentation. + project.pluginManager.apply('jacoco') + project.jacoco { + toolVersion = '0.8.14' + } + project.tasks.withType(Test).configureEach { testTask -> + testTask.jacoco.includeNoLocationClasses = true + testTask.jacoco.excludes = ['jdk.internal.*'] + } + android { - // Instrument all our modules for coverage generation - buildTypes.debug.testCoverageEnabled = true + buildTypes.debug { + // Detox / on-device coverage.ec needs instrumented library classes in the app. + enableAndroidTestCoverage = true + // Do not use enableUnitTestCoverage here — empty product probes on library modules. + enableUnitTestCoverage = false + } jacoco.version = '0.8.14' testOptions.unitTests.includeAndroidResources = true diff --git a/tests/scripts/pull-native-coverage.js b/tests/scripts/pull-native-coverage.js index 6ff7648021..8a4048d33e 100644 --- a/tests/scripts/pull-native-coverage.js +++ b/tests/scripts/pull-native-coverage.js @@ -142,9 +142,11 @@ function pullIosCoverage(deviceId, options = {}) { return destPaths; } -function runJacocoAndroidTestReport() { +// Merged unit (*.exec) + e2e (*.ec) report — Codecov android-native uploads this XML. +// See tests/android/app/jacoco.gradle (jacocoTestReport) and okf-bundle/testing/coverage-design.md. +function runJacocoTestReport() { const androidDir = path.resolve(__dirname, '../android'); - const result = spawnSync('./gradlew', ['jacocoAndroidTestReport'], { + const result = spawnSync('./gradlew', ['jacocoTestReport'], { cwd: androidDir, stdio: 'inherit', shell: true, @@ -152,7 +154,7 @@ function runJacocoAndroidTestReport() { if (result.status !== 0) { console.warn( - `[native-coverage] jacocoAndroidTestReport exited with status ${result.status ?? 'unknown'}`, + `[native-coverage] jacocoTestReport exited with status ${result.status ?? 'unknown'}`, ); return false; } @@ -194,9 +196,11 @@ async function main() { } else { pulled = await pullAndroidCoverageWithRetry(deviceId, { softFail: true, testsDir }); } - const reportOk = runJacocoAndroidTestReport(); + const reportOk = runJacocoTestReport(); if (!pulled) { - console.warn('[native-coverage] Jacoco report may be empty (no coverage.ec pulled)'); + console.warn( + '[native-coverage] Merged Jacoco report may lack e2e data (no coverage.ec pulled)', + ); } else if (reportOk) { deleteProcessedAndroidCoverageEc(pulled); } @@ -221,5 +225,5 @@ module.exports = { pullAndroidCoverageWithRetry, pullIosCoverage, resolveAndroidDeviceId, - runJacocoAndroidTestReport, + runJacocoTestReport, };