From 43475d97806ca92017edfa06e8d97c7eee723fc1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliv=C3=A9r=20Falvai?= Date: Tue, 18 Aug 2026 19:36:44 +0200 Subject: [PATCH] Android: introduce unit tests and Kotlin --- .github/workflows/ci-test.yml | 38 ++++++++- CLAUDE.md | 18 +++- .../CodePushDemo/android/gradle.properties | 7 ++ android/app/build.gradle | 82 +++++++++++++++++-- .../codepush/react/CodePushUtilsTest.kt | 24 ++++++ android/build.gradle | 7 +- android/gradle.properties | 15 ++++ .../gradle/wrapper/gradle-wrapper.properties | 2 +- 8 files changed, 182 insertions(+), 11 deletions(-) create mode 100644 android/app/src/test/java/com/microsoft/codepush/react/CodePushUtilsTest.kt diff --git a/.github/workflows/ci-test.yml b/.github/workflows/ci-test.yml index 2d017cfd..c0f4487f 100644 --- a/.github/workflows/ci-test.yml +++ b/.github/workflows/ci-test.yml @@ -21,8 +21,34 @@ jobs: - name: Check public API compatibility run: npm run check:api-compat - android-test: + android-unit-test: needs: lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - uses: jdx/mise-action@v4 + + - name: Cache npm packages + uses: actions/cache@v6 + with: + path: ~/.npm + key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json', 'test/test.ts') }} + restore-keys: | + ${{ runner.os }}-npm- + + - name: Install dependencies + run: npm install + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v6 + + - name: Run Android unit tests + working-directory: android + run: ./gradlew :app:test + + android-test: + needs: [lint, android-unit-test] runs-on: bitrise-react-native-code-push-linux-runner strategy: matrix: @@ -49,6 +75,16 @@ jobs: - name: Install dependencies run: npm install + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v6 + with: + # This job's Gradle build lives in a test app generated at runtime by the test harness, + # not a project checked into this repo, so there's nothing meaningful for it to + # contribute back to the cache. Read-only avoids each matrix arm (bare/expo) writing + # its own redundant entry; it still reuses whatever android-unit-test wrote, since + # setup-gradle shares its cache across jobs in the same workflow run. + cache-read-only: true + - name: Enable KVM run: | echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules diff --git a/CLAUDE.md b/CLAUDE.md index 1e3d54df..4c947cb7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,7 +7,13 @@ React Native CodePush is a native module that enables over-the-air updates for R ## Development Commands ### Testing -- `npm test` - Run all tests with TypeScript compilation + +#### Unit tests + +- `cd android && ./gradlew :app:test` +- iOS: no unit tests yet. + +#### E2E Tests - `npm run test:android` - Run Android-specific tests - `npm run test:ios` - Run iOS-specific tests - `npm run test:setup-android` - Set up Android emulator for testing @@ -34,7 +40,7 @@ React Native CodePush is a native module that enables over-the-air updates for R ### Platform Structure - **iOS**: `ios/` - Objective-C implementation with CocoaPods integration -- **Android**: `android/` - Java implementation with Gradle plugin +- **Android**: `android/` - Java implementation with Gradle plugin (Kotlin supported for unit tests and new prod code, see "Android Unit Testing & Kotlin" below) - **Windows**: `windows/` - C++ implementation for Windows React Native - **JavaScript**: Root level - TypeScript definitions and bridge code @@ -48,7 +54,7 @@ React Native CodePush is a native module that enables over-the-air updates for R - **Custom Test Runner**: TypeScript-based test framework in `test/` - **Real App Testing**: Creates actual React Native apps for integration testing - **Scenario Testing**: Update, rollback, and error scenarios -- **No unit test infra yet**: this repo only has the mocha-based integration suite above. `src/acquisition-sdk/__tests__/` contains tests ported from upstream `microsoft/code-push`, kept for future reference - they are deliberately not wired into `npm test` or any runner. Don't assume they're dead/forgotten code, and don't wire them in without setting up real unit test infra first. +- **No unit test infra for JS/iOS yet** (Android now has some — see "Android Unit Testing & Kotlin" below): JS/iOS only have the mocha-based integration suite above. `src/acquisition-sdk/__tests__/` contains tests ported from upstream `microsoft/code-push`, kept for future reference - they are deliberately not wired into `npm test` or any runner. Don't assume they're dead/forgotten code, and don't wire them in without setting up real unit test infra first. - **Templates**: `test/template/` holds native files (Podfile, AppDelegate, Android app files) and JS scenarios copied over top of a freshly generated RN/Expo app during test setup, overwriting its defaults — edit files here, not the generated project, for changes to persist - **`test:ios` vs `test:setup:ios` vs `test:fast:ios`**: `test:ios` is just `test:setup:ios` followed by `test:fast:ios` — the two are meant to be split apart for local iteration. - `test:setup:ios` (mocha `--ios --setup`) boots the simulator and provisions the test app once: copies templates, runs `pod install`, patches Info.plist/AppDelegate. It never builds or runs any test scenario. @@ -60,6 +66,12 @@ React Native CodePush is a native module that enables over-the-air updates for R - `npm run test:setup:ios` provisions a full test app outside the repo (under a system temp/`test-run` dir), not inside `test/` — expect to search for it rather than finding it checked into the repo tree. - The provisioned test app's `node_modules/@bitrise/code-push-sdk` is a real copy, not a symlink — editing `ios/` (or `android/`) native source in the repo has zero effect on `test:fast:ios` runs until you re-copy those files into that `node_modules` path (or rerun `test:setup:ios`). +### Android Unit Testing & Kotlin +- New unit tests should be written in Kotlin; new non-test prod code too, going forward. +- Gotcha: only `android/app/build.gradle` is ever loaded by a downstream consumer app (RN autolinking includes it as a Gradle subproject inside the consumer's own build). The rest of `android/`'s config (root `build.gradle`, `gradle.properties`, wrapper) only governs this repo's own standalone test harness — never assume a change there affects consumers. +- Gotcha: `android/app/build.gradle` gates the classic `kotlin-android` plugin/`kotlinOptions` on `agpMajor < 9` (AGP9+ provides Kotlin built in; applying the classic plugin on top of it fails). This matches how other RN libraries adapted for AGP9 — see the patches linked from the [AGP v9 adoption RFC](https://github.com/react-native-community/discussions-and-proposals/pull/1006). This repo's own harness and `Examples/CodePushDemo` both opt out of AGP9's built-in Kotlin/new DSL via `android.builtInKotlin=false`/`android.newDsl=false` in `gradle.properties`, matching RN's own 0.87 app template (that opt-out is removed starting AGP10, per RN's 0.87 release notes) — but build.gradle's `agpMajor` check doesn't depend on that flag either way, since AGP9's built-in Kotlin compiler stays active regardless of it. +- Gotcha: `android/build.gradle`'s AGP version must track what `node_modules/@react-native/gradle-plugin` (an included composite build) itself depends on — an outdated pin here is silently overridden rather than enforced, surfacing instead as a confusing "Gradle version too old" failure. Bump `android/gradle/wrapper/gradle-wrapper.properties` in lockstep whenever bumping `react-native`. + ### Build Integration - **Android Gradle Plugin**: Automatically generates bundle hashes and processes assets - **iOS CocoaPods**: Manages native dependencies and build configuration diff --git a/Examples/CodePushDemo/android/gradle.properties b/Examples/CodePushDemo/android/gradle.properties index 9afe6159..9aa3b343 100644 --- a/Examples/CodePushDemo/android/gradle.properties +++ b/Examples/CodePushDemo/android/gradle.properties @@ -42,3 +42,10 @@ hermesEnabled=true # This allows your app to draw behind system bars for an immersive UI. # Note: Only works with ReactActivity and should not be used with custom Activity. edgeToEdgeEnabled=false + +# Opt out of AGP 9's built-in Kotlin support and new DSL, matching what RN's own 0.87 app template +# does (this flag pair is removed in AGP10, at which point built-in Kotlin becomes mandatory — +# see the RN 0.87 release notes and the AGP v9 adoption RFC: +# https://github.com/react-native-community/discussions-and-proposals/pull/1006). +android.builtInKotlin=false +android.newDsl=false diff --git a/android/app/build.gradle b/android/app/build.gradle index 9e5eeac9..f05b42e2 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -1,5 +1,46 @@ +buildscript { + // Read the Kotlin version from the consuming app's rootProject.ext first (most RN apps + // already set this for reanimated/gesture-handler/screens/etc.), falling back to a bundled + // default only when nothing else in the build graph has declared one. We deliberately use + // the classic `buildscript { classpath ... } + apply plugin` form here instead of the + // `plugins { id(...) version(...) }` DSL: the latter hard-fails the whole consumer build with + // "plugin already on the classpath with a different version" if any other subproject (e.g. + // reanimated) applies a different Kotlin version, since it enforces exact version matches per + // plugin id across the whole multi-project build. The classpath form just resolves to a + // single version (highest wins) with no such conflict. + // + // Fallback version has no ecosystem anchor to track — it only applies when nothing else in + // the host build declared a Kotlin version at all, which is already rare in the RN ecosystem. + // Kept aligned with what @react-native/gradle-plugin itself bundles (RN 0.87's `kotlin` in + // its libs.versions.toml) since that's the most common actual value in the wild. + def kotlinVersion = rootProject.ext.has("kotlinVersion") ? rootProject.ext.get("kotlinVersion") : "2.2.0" + + repositories { + google() + mavenCentral() + } + dependencies { + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion" + } +} + apply plugin: "com.android.library" +// AGP 9 provides Kotlin support built in; applying the classic kotlin-android plugin on top of it +// fails with "Cannot add extension with name 'kotlin', as there is an extension already +// registered with that name." Below AGP 9, the classic plugin is still required. Empirically, +// AGP 9's built-in Kotlin compilation stays active even when a consumer opts out of the new +// DSL/extension via `android.builtInKotlin=false` (RN's own 0.87 template does this — see its +// release notes; the opt-out itself is removed in AGP10), so the AGP major version alone is +// enough to decide, no need to probe for what the consumer opted into. This matches how other RN +// libraries migrated for AGP9 — see the patches linked from the AGP v9 adoption RFC +// (https://github.com/react-native-community/discussions-and-proposals/pull/1006), e.g. +// react-native-safe-area-context's. +def agpMajor = com.android.Version.ANDROID_GRADLE_PLUGIN_VERSION.tokenize('.')[0].toInteger() +if (agpMajor < 9) { + apply plugin: "kotlin-android" +} + def isNewArchitectureEnabled() { // To opt-in for the New Architecture, you can either: // - Set `newArchEnabled` to true inside the `gradle.properties` file @@ -10,15 +51,22 @@ def isNewArchitectureEnabled() { def IS_NEW_ARCHITECTURE_ENABLED = isNewArchitectureEnabled() -def DEFAULT_COMPILE_SDK_VERSION = 26 -def DEFAULT_BUILD_TOOLS_VERSION = "26.0.3" -def DEFAULT_TARGET_SDK_VERSION = 26 -def DEFAULT_MIN_SDK_VERSION = 16 +// These are fallbacks only — real consumer apps set compileSdkVersion/buildToolsVersion/ +// targetSdkVersion/minSdkVersion on rootProject themselves (RN's own template always has), +// so bumping these defaults doesn't change behavior for any app that already does. Keep them +// aligned with RN's current app template (verified against Examples/CodePushDemo's root +// build.gradle, generated from RN 0.87's template) so a consumer relying on the fallback still +// gets a build that actually works with a current RN version. +def DEFAULT_COMPILE_SDK_VERSION = 35 // RN template: compileSdkVersion +def DEFAULT_BUILD_TOOLS_VERSION = "36.0.0" // RN template: buildToolsVersion (also AGP 9.2.1's own minimum) +def DEFAULT_TARGET_SDK_VERSION = 35 // RN template: targetSdkVersion +def DEFAULT_MIN_SDK_VERSION = 24 // RN template: minSdkVersion (RN 0.74+'s own minimum) android { namespace "com.microsoft.codepush.react" - compileSdkVersion rootProject.hasProperty('compileSdkVersion') ? rootProject.compileSdkVersion : DEFAULT_COMPILE_SDK_VERSION + // `compileSdk` (not the old `compileSdkVersion(int)` method) — AGP 9 removed that overload. + compileSdk rootProject.hasProperty('compileSdkVersion') ? rootProject.compileSdkVersion : DEFAULT_COMPILE_SDK_VERSION buildToolsVersion rootProject.hasProperty('buildToolsVersion') ? rootProject.buildToolsVersion : DEFAULT_BUILD_TOOLS_VERSION defaultConfig { @@ -40,9 +88,33 @@ android { buildFeatures { buildConfig true } + + // JVM 17 matches mise.toml's `java = "17"` pin (this repo's own toolchain) and RN 0.76+'s own + // minimum required JDK — not an arbitrary choice. Confirmed against Examples/CodePushDemo: + // its build resolves unit-test javac to JVM 17 regardless of what's set here, so this module + // must match it or Kotlin's build fails with "Inconsistent JVM Target Compatibility". Bump + // this in lockstep with mise.toml's java version, never independently. + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } +} + +// Only set when we applied the classic kotlin-android plugin ourselves above: AGP 9's built-in +// Kotlin integration doesn't expose this extension at all (`android.kotlinOptions` fails with +// "Could not find method kotlinOptions()"). Not needed there anyway — per Android's own built-in +// Kotlin migration guide, `kotlin.compilerOptions.jvmTarget` defaults to +// `android.compileOptions.targetCompatibility` (set above) when built-in Kotlin is active: +// https://developer.android.com/build/migrate-to-built-in-kotlin +if (agpMajor < 9) { + android.kotlinOptions { + jvmTarget = "17" + } } dependencies { implementation 'com.facebook.react:react-android:0.82.1' implementation 'com.nimbusds:nimbus-jose-jwt:9.37.3' + + testImplementation 'junit:junit:4.13.2' } diff --git a/android/app/src/test/java/com/microsoft/codepush/react/CodePushUtilsTest.kt b/android/app/src/test/java/com/microsoft/codepush/react/CodePushUtilsTest.kt new file mode 100644 index 00000000..bc9b0ec0 --- /dev/null +++ b/android/app/src/test/java/com/microsoft/codepush/react/CodePushUtilsTest.kt @@ -0,0 +1,24 @@ +package com.microsoft.codepush.react + +import org.junit.Assert.assertEquals +import org.junit.Test +import java.io.File + +class CodePushUtilsTest { + + @Test + fun appendPathComponent_joinsBaseAndChildIntoAbsolutePath() { + val basePath = "/data/data/com.example.app/files" + val result = CodePushUtils.appendPathComponent(basePath, "CodePush") + + assertEquals(File(basePath, "CodePush").absolutePath, result) + } + + @Test + fun appendPathComponent_handlesNestedRelativeComponent() { + val basePath = "/data/data/com.example.app/files/CodePush" + val result = CodePushUtils.appendPathComponent(basePath, "20/CodePushLoader.android.bundle") + + assertEquals(File(basePath, "20/CodePushLoader.android.bundle").absolutePath, result) + } +} diff --git a/android/build.gradle b/android/build.gradle index 989373d7..a45423e2 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -6,7 +6,12 @@ buildscript { mavenCentral() } dependencies { - classpath("com.android.tools.build:gradle:8.12.0") + // Must match (or exceed) the AGP version RN's own @react-native/gradle-plugin declares: that plugin is + // included as a composite build below and its own AGP dependency wins Gradle's classpath + // conflict resolution regardless of what's pinned here, so an out-of-date pin here is + // silently overridden rather than actually enforced. Bump this in lockstep with RN's own + // gradle-plugin version whenever bumping the react-native devDependency. + classpath("com.android.tools.build:gradle:9.2.1") classpath("com.facebook.react:react-native-gradle-plugin") // NOTE: Do not place your application dependencies here; they belong diff --git a/android/gradle.properties b/android/gradle.properties index 89e0d99e..575da9cc 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -16,3 +16,18 @@ # This option should only be used with decoupled projects. More details, visit # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects # org.gradle.parallel=true + +# This repo's own standalone build/test harness only — real consumer apps already set this +# (RN's own template has for years), so it's never actually read by a downstream build. Needed +# here because react-android pulls in AndroidX transitively, and Gradle's unit test task +# resolves that classpath strictly. +android.useAndroidX=true + +# Opt out of AGP 9's built-in Kotlin support and new DSL, matching what RN's own 0.87 app template +# does (this flag pair is removed in AGP10, at which point built-in Kotlin becomes mandatory — +# see the RN 0.87 release notes and the AGP v9 adoption RFC: +# https://github.com/react-native-community/discussions-and-proposals/pull/1006). This repo's own +# harness only; see the note above and android/app/build.gradle's comments for why downstream +# consumers may still land in either mode. +android.builtInKotlin=false +android.newDsl=false diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index 74b269f3..da80f8d1 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-all.zip