diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 558b039..a59a7d4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -103,6 +103,44 @@ jobs: run: | yarn turbo run build:android --cache-dir="${{ env.TURBO_CACHE_DIR }}" + test-android: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Setup + uses: ./.github/actions/setup + + - name: Install JDK + uses: actions/setup-java@v4 + with: + distribution: 'zulu' + java-version: '17' + + - name: Finalize Android SDK + run: | + /bin/bash -c "yes | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --licenses > /dev/null" + + - name: Cache Gradle + uses: actions/cache@v4 + with: + path: | + ~/.gradle/wrapper + ~/.gradle/caches + key: ${{ runner.os }}-gradle-${{ hashFiles('example/android/gradle/wrapper/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- + + - name: Run Android unit tests + run: | + cd example/android + ./gradlew :ionic_portals-react-native:testDebugUnitTest --no-daemon --console=plain + + - name: Summarize unit test results + if: always() + run: grep -h " Boolean, action: () -> Unit) { + view.post(object : Runnable { + override fun run() { + if (!isCurrent()) return + if (!view.isAttachedToWindow) { + view.post(this) + return + } + action() + } + }) +} diff --git a/android/src/test/java/io/ionic/portals/reactnative/RunWhenAttachedTest.kt b/android/src/test/java/io/ionic/portals/reactnative/RunWhenAttachedTest.kt new file mode 100644 index 0000000..353b90a --- /dev/null +++ b/android/src/test/java/io/ionic/portals/reactnative/RunWhenAttachedTest.kt @@ -0,0 +1,136 @@ +package io.ionic.portals.reactnative + +import android.os.Looper +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentActivity +import androidx.fragment.app.FragmentManager +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config + +/** + * Regression tests for the container race behind + * `IllegalArgumentException: No view found for id … for fragment PortalFragment`. + * + * They drive the real androidx [FragmentManager] under Robolectric with plain + * [Fragment]s, which is enough: `FragmentStateManager.createView` resolves the + * container by id before it asks the fragment for a view. `PortalFragment` + * itself is out of scope here, it would boot a Capacitor bridge and a WebView. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class RunWhenAttachedTest { + private lateinit var activity: FragmentActivity + private lateinit var container: FrameLayout + + private val fragmentManager: FragmentManager + get() = activity.supportFragmentManager + + @Before + fun setUp() { + activity = Robolectric.buildActivity(FragmentActivity::class.java).setup().get() + container = FrameLayout(activity).apply { id = View.generateViewId() } + } + + private fun attachContainer() = activity.setContentView(container) + + private fun detachContainer() = (container.parent as ViewGroup).removeView(container) + + private fun idleMainLooper() = shadowOf(Looper.getMainLooper()).idle() + + private fun commitFragmentNow() { + fragmentManager + .beginTransaction() + .replace(container.id, Fragment()) + .commitNowAllowingStateLoss() + } + + private fun fragmentInContainer(): Fragment? = fragmentManager.findFragmentById(container.id) + + /** + * Control: this is the pattern the view manager used before the fix and the + * production crash. It proves the harness reproduces the failure, so the + * tests below mean something. + */ + @Test + fun asyncCommitThrowsWhenTheContainerWasRemovedBeforeItRan() { + attachContainer() + fragmentManager.beginTransaction().replace(container.id, Fragment()).commit() + detachContainer() + + val thrown = runCatching { idleMainLooper() }.exceptionOrNull() + + assertNotNull("expected the pending add to throw", thrown) + val messages = generateSequence(thrown) { it.cause } + .mapNotNull { it.message } + .joinToString(" | ") + assertTrue(messages, messages.contains("No view found for id")) + } + + @Test + fun commitsOnceTheLooperRunsWhenTheContainerIsAttached() { + attachContainer() + + runWhenAttached(container, isCurrent = { true }, action = ::commitFragmentNow) + idleMainLooper() + + assertTrue(fragmentInContainer()?.isAdded == true) + } + + @Test + fun skipsTheCommitWhenTheViewWasDroppedBeforeItRan() { + attachContainer() + var actionRuns = 0 + + runWhenAttached(container, isCurrent = { false }) { + actionRuns++ + commitFragmentNow() + } + detachContainer() + idleMainLooper() + + assertEquals(0, actionRuns) + assertNull(fragmentInContainer()) + } + + @Test + fun waitsForTheContainerToAttachBeforeCommitting() { + // Not attached yet: mirrors a screen that is still animating in. + runWhenAttached(container, isCurrent = { true }, action = ::commitFragmentNow) + idleMainLooper() + assertNull("must not commit before the container is in the window", fragmentInContainer()) + + attachContainer() + idleMainLooper() + + assertTrue(fragmentInContainer()?.isAdded == true) + } + + @Test + fun doesNotThrowWhenTheContainerIsDetachedWhileStillCurrent() { + // Mirrors a screen detached natively while React still has the view mounted. + attachContainer() + runWhenAttached(container, isCurrent = { true }, action = ::commitFragmentNow) + detachContainer() + + idleMainLooper() + + assertNull(fragmentInContainer()) + + // ...and the commit still happens once the container comes back. + attachContainer() + idleMainLooper() + assertTrue(fragmentInContainer()?.isAdded == true) + } +}