From 2c80b9294b412862db855a011538489e40deea80 Mon Sep 17 00:00:00 2001 From: unknown Date: Fri, 3 Apr 2026 12:22:31 -0400 Subject: [PATCH 1/8] constructViewTest: create ReactHostImpl directly for bridgeless isolation Removes dependency on MainApplication by constructing ReactHostImpl directly in the test with dev support and packager access disabled. Co-Authored-By: Claude Sonnet 4.6 --- .../java/com/testapp/IsolatedTest.kt | 39 ++++++++++++++++--- 1 file changed, 34 insertions(+), 5 deletions(-) diff --git a/android/app/src/androidTest/java/com/testapp/IsolatedTest.kt b/android/app/src/androidTest/java/com/testapp/IsolatedTest.kt index 59c03d0..753e47e 100644 --- a/android/app/src/androidTest/java/com/testapp/IsolatedTest.kt +++ b/android/app/src/androidTest/java/com/testapp/IsolatedTest.kt @@ -1,8 +1,17 @@ package com.rnstorybookautoscreenshots +import android.app.Application import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry -import com.testapp.MainApplication +import com.facebook.react.PackageList +import com.facebook.react.common.annotations.UnstableReactNativeAPI +import com.facebook.react.bridge.JSBundleLoader +import com.facebook.react.defaults.DefaultComponentsRegistry +import com.facebook.react.defaults.DefaultReactHostDelegate +import com.facebook.react.defaults.DefaultTurboModuleManagerDelegate +import com.facebook.react.fabric.ComponentFactory +import com.facebook.react.runtime.ReactHostImpl +import com.facebook.react.runtime.hermes.HermesInstance import junit.framework.TestCase.assertNotNull import junit.framework.TestCase.assertTrue import org.junit.Test @@ -19,16 +28,36 @@ class IsolatedTest { assertTrue(true) } + @OptIn(UnstableReactNativeAPI::class) @Test fun constructViewTest() { val context = InstrumentationRegistry.getInstrumentation().targetContext - val app = context.applicationContext as MainApplication - val surface = app.reactHost.createSurface(context, "SimpleTestComponent", null) + val app = context.applicationContext as Application + + val bundleLoader = JSBundleLoader.createAssetLoader( + context, "assets://index.android.bundle", true) + val delegate = DefaultReactHostDelegate( + jsMainModulePath = "index", + jsBundleLoader = bundleLoader, + reactPackages = PackageList(app).packages, + jsRuntimeFactory = HermesInstance(), + turboModuleManagerDelegateBuilder = DefaultTurboModuleManagerDelegate.Builder(), + exceptionHandler = { throw it }, + ) + val componentFactory = ComponentFactory() + DefaultComponentsRegistry.register(componentFactory) + val reactHost = ReactHostImpl( + context, + delegate, + componentFactory, + false, // allowPackagerServerAccess + false, // useDevSupport + ) + + val surface = reactHost.createSurface(context, "SimpleTestComponent", null) assertEquals("SimpleTestComponent", surface.moduleName) // TODO: we aren't 100% sure if prerender() and start() are being called the way we want it to. - // We probably want to create a ReactHost directly instead of taking it from the MainApplication... probably - // Also look up bridge-less mode. assertGoodTask(surface.prerender()) From 9f3ffe2c1f2580d4e065bd4889f35858e283535b Mon Sep 17 00:00:00 2001 From: unknown Date: Fri, 3 Apr 2026 14:43:31 -0400 Subject: [PATCH 2/8] Fix blank screenshot in constructViewTest by using WindowManager overlay - Replace ViewHelpers.setupView with WindowManager.TYPE_APPLICATION_OVERLAY so Fabric's Choreographer can fire mutations against a real Window - Add reactHost.onHostResume(null) so Fabric commits its render tree (without RESUMED lifecycle state, all mutations are silently dropped) - Wait for ReactMarkerConstants.CONTENT_APPEARED before snapping screenshot - Set software layer type so Screenshot.snap() can capture via draw(canvas) - Grant SYSTEM_ALERT_WINDOW permission required for overlay window type Co-Authored-By: Claude Sonnet 4.6 --- .../java/com/testapp/IsolatedTest.kt | 68 +++++++++++++++---- 1 file changed, 53 insertions(+), 15 deletions(-) diff --git a/android/app/src/androidTest/java/com/testapp/IsolatedTest.kt b/android/app/src/androidTest/java/com/testapp/IsolatedTest.kt index 753e47e..839a709 100644 --- a/android/app/src/androidTest/java/com/testapp/IsolatedTest.kt +++ b/android/app/src/androidTest/java/com/testapp/IsolatedTest.kt @@ -1,9 +1,16 @@ package com.rnstorybookautoscreenshots +import android.Manifest import android.app.Application +import android.graphics.PixelFormat +import android.view.View +import android.view.WindowManager import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.rule.GrantPermissionRule import com.facebook.react.PackageList +import com.facebook.react.bridge.ReactMarker +import com.facebook.react.bridge.ReactMarkerConstants import com.facebook.react.common.annotations.UnstableReactNativeAPI import com.facebook.react.bridge.JSBundleLoader import com.facebook.react.defaults.DefaultComponentsRegistry @@ -14,15 +21,22 @@ import com.facebook.react.runtime.ReactHostImpl import com.facebook.react.runtime.hermes.HermesInstance import junit.framework.TestCase.assertNotNull import junit.framework.TestCase.assertTrue +import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith -import com.facebook.testing.screenshot.ViewHelpers import com.facebook.testing.screenshot.Screenshot import org.junit.Assert.*; import com.facebook.react.interfaces.* +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit @RunWith(AndroidJUnit4::class) class IsolatedTest { + + @get:Rule + val overlayPermission: GrantPermissionRule = + GrantPermissionRule.grant(Manifest.permission.SYSTEM_ALERT_WINDOW) + @Test fun simpleTest() { assertTrue(true) @@ -31,7 +45,8 @@ class IsolatedTest { @OptIn(UnstableReactNativeAPI::class) @Test fun constructViewTest() { - val context = InstrumentationRegistry.getInstrumentation().targetContext + val instrumentation = InstrumentationRegistry.getInstrumentation() + val context = instrumentation.targetContext val app = context.applicationContext as Application val bundleLoader = JSBundleLoader.createAssetLoader( @@ -56,24 +71,47 @@ class IsolatedTest { val surface = reactHost.createSurface(context, "SimpleTestComponent", null) assertEquals("SimpleTestComponent", surface.moduleName) - - // TODO: we aren't 100% sure if prerender() and start() are being called the way we want it to. - assertGoodTask(surface.prerender()) - - assertNotNull(surface.view) - ViewHelpers.setupView(surface.view!!) - .setExactHeightPx(1000) - .setExactWidthPx(1000) - .layout() + val view = surface.view!! + val wm = context.getSystemService(android.content.Context.WINDOW_SERVICE) as WindowManager + val params = WindowManager.LayoutParams( + 1080, + 1920, + WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY, + WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE, + PixelFormat.TRANSLUCENT + ).apply { + // alpha=0 so the compositor skips drawing while Fabric still sees a real Window. + alpha = 0f + } + + val renderLatch = CountDownLatch(1) + val markerListener = ReactMarker.MarkerListener { name, _, _ -> + if (name == ReactMarkerConstants.CONTENT_APPEARED) renderLatch.countDown() + } + ReactMarker.addListener(markerListener) + try { + instrumentation.runOnMainSync { + // RESUMED state is required for Fabric to commit mutations to the view. + reactHost.onHostResume(null) + // Software rendering so Screenshot.snap() can capture via draw(canvas). + view.setLayerType(View.LAYER_TYPE_SOFTWARE, null) + wm.addView(view, params) + surface.start() + } - val ti = surface.start() - assertGoodTask(ti) + assertTrue("Timed out waiting for first render", renderLatch.await(10, TimeUnit.SECONDS)) - Screenshot.snap(surface.view!!) - .record() + Screenshot.snap(view).record() + } finally { + ReactMarker.removeListener(markerListener) + instrumentation.runOnMainSync { + surface.stop() + wm.removeView(view) + } + } } } From 77c8864916d83dd4cb9dd26d091ca23fcdca25f4 Mon Sep 17 00:00:00 2001 From: unknown Date: Fri, 3 Apr 2026 15:01:03 -0400 Subject: [PATCH 3/8] Set white background on view before taking screenshot Prevents checkered (transparent) background in captured screenshots. Co-Authored-By: Claude Sonnet 4.6 --- android/app/src/androidTest/java/com/testapp/IsolatedTest.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/android/app/src/androidTest/java/com/testapp/IsolatedTest.kt b/android/app/src/androidTest/java/com/testapp/IsolatedTest.kt index 839a709..3262729 100644 --- a/android/app/src/androidTest/java/com/testapp/IsolatedTest.kt +++ b/android/app/src/androidTest/java/com/testapp/IsolatedTest.kt @@ -2,6 +2,7 @@ package com.rnstorybookautoscreenshots import android.Manifest import android.app.Application +import android.graphics.Color import android.graphics.PixelFormat import android.view.View import android.view.WindowManager @@ -98,6 +99,7 @@ class IsolatedTest { reactHost.onHostResume(null) // Software rendering so Screenshot.snap() can capture via draw(canvas). view.setLayerType(View.LAYER_TYPE_SOFTWARE, null) + view.setBackgroundColor(Color.WHITE) wm.addView(view, params) surface.start() } From d649d8c16d4d75a0e7179d5f90d46669bb087c56 Mon Sep 17 00:00:00 2001 From: unknown Date: Mon, 6 Apr 2026 16:24:16 -0400 Subject: [PATCH 4/8] Use WindowAttachment.dispatchAttach instead of WindowManager overlay Replaces the TYPE_APPLICATION_OVERLAY window (which required SYSTEM_ALERT_WINDOW permission) with WindowAttachment.dispatchAttach, which fakes window attachment via reflection without a real window. Co-Authored-By: Claude Sonnet 4.6 --- .../java/com/testapp/IsolatedTest.kt | 32 ++++--------------- 1 file changed, 7 insertions(+), 25 deletions(-) diff --git a/android/app/src/androidTest/java/com/testapp/IsolatedTest.kt b/android/app/src/androidTest/java/com/testapp/IsolatedTest.kt index 3262729..9275554 100644 --- a/android/app/src/androidTest/java/com/testapp/IsolatedTest.kt +++ b/android/app/src/androidTest/java/com/testapp/IsolatedTest.kt @@ -1,14 +1,10 @@ package com.rnstorybookautoscreenshots -import android.Manifest import android.app.Application import android.graphics.Color -import android.graphics.PixelFormat import android.view.View -import android.view.WindowManager import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry -import androidx.test.rule.GrantPermissionRule import com.facebook.react.PackageList import com.facebook.react.bridge.ReactMarker import com.facebook.react.bridge.ReactMarkerConstants @@ -22,10 +18,11 @@ import com.facebook.react.runtime.ReactHostImpl import com.facebook.react.runtime.hermes.HermesInstance import junit.framework.TestCase.assertNotNull import junit.framework.TestCase.assertTrue -import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import com.facebook.testing.screenshot.Screenshot +import com.facebook.testing.screenshot.ViewHelpers +import com.facebook.testing.screenshot.WindowAttachment import org.junit.Assert.*; import com.facebook.react.interfaces.* import java.util.concurrent.CountDownLatch @@ -34,10 +31,6 @@ import java.util.concurrent.TimeUnit @RunWith(AndroidJUnit4::class) class IsolatedTest { - @get:Rule - val overlayPermission: GrantPermissionRule = - GrantPermissionRule.grant(Manifest.permission.SYSTEM_ALERT_WINDOW) - @Test fun simpleTest() { assertTrue(true) @@ -75,17 +68,6 @@ class IsolatedTest { assertNotNull(surface.view) val view = surface.view!! - val wm = context.getSystemService(android.content.Context.WINDOW_SERVICE) as WindowManager - val params = WindowManager.LayoutParams( - 1080, - 1920, - WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY, - WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE, - PixelFormat.TRANSLUCENT - ).apply { - // alpha=0 so the compositor skips drawing while Fabric still sees a real Window. - alpha = 0f - } val renderLatch = CountDownLatch(1) val markerListener = ReactMarker.MarkerListener { name, _, _ -> @@ -93,25 +75,25 @@ class IsolatedTest { } ReactMarker.addListener(markerListener) + var detacher: WindowAttachment.Detacher? = null + try { instrumentation.runOnMainSync { - // RESUMED state is required for Fabric to commit mutations to the view. reactHost.onHostResume(null) - // Software rendering so Screenshot.snap() can capture via draw(canvas). view.setLayerType(View.LAYER_TYPE_SOFTWARE, null) view.setBackgroundColor(Color.WHITE) - wm.addView(view, params) + detacher = WindowAttachment.dispatchAttach(view) + ViewHelpers.setupView(view).setExactWidthPx(1080).setExactHeightPx(1920).layout() surface.start() } assertTrue("Timed out waiting for first render", renderLatch.await(10, TimeUnit.SECONDS)) - Screenshot.snap(view).record() } finally { ReactMarker.removeListener(markerListener) instrumentation.runOnMainSync { surface.stop() - wm.removeView(view) + detacher?.detach() } } } From a05ae98802566fc6f3b3d4feeb411db02992718b Mon Sep 17 00:00:00 2001 From: unknown Date: Tue, 7 Apr 2026 17:41:01 -0400 Subject: [PATCH 5/8] Take screenshot inside runOnMainSync to reduce non-determinism Screenshot.snap was being called on the test thread after the latch released. Moving it into runOnMainSync ensures the view is snapped on the UI thread, removing one source of non-determinism. Co-Authored-By: Claude Sonnet 4.6 --- android/app/src/androidTest/java/com/testapp/IsolatedTest.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/android/app/src/androidTest/java/com/testapp/IsolatedTest.kt b/android/app/src/androidTest/java/com/testapp/IsolatedTest.kt index 9275554..b631c86 100644 --- a/android/app/src/androidTest/java/com/testapp/IsolatedTest.kt +++ b/android/app/src/androidTest/java/com/testapp/IsolatedTest.kt @@ -88,7 +88,9 @@ class IsolatedTest { } assertTrue("Timed out waiting for first render", renderLatch.await(10, TimeUnit.SECONDS)) - Screenshot.snap(view).record() + instrumentation.runOnMainSync { + Screenshot.snap(view).record() + } } finally { ReactMarker.removeListener(markerListener) instrumentation.runOnMainSync { From 3dffa645d684ea1470c9860a036bddfcfd13dd5d Mon Sep 17 00:00:00 2001 From: unknown Date: Tue, 7 Apr 2026 18:37:59 -0400 Subject: [PATCH 6/8] Put screenshot snap inside runOnMainSync and set explicit name Per Arnold's review: collapse surface.start() and Screenshot.snap() into one runOnMainSync to reduce non-determinism, and pass an explicit name since snap() can't infer the test name from the main thread. Co-Authored-By: Claude Sonnet 4.6 --- .../androidTest/java/com/testapp/IsolatedTest.kt | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/android/app/src/androidTest/java/com/testapp/IsolatedTest.kt b/android/app/src/androidTest/java/com/testapp/IsolatedTest.kt index b631c86..e4a7747 100644 --- a/android/app/src/androidTest/java/com/testapp/IsolatedTest.kt +++ b/android/app/src/androidTest/java/com/testapp/IsolatedTest.kt @@ -6,8 +6,6 @@ import android.view.View import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import com.facebook.react.PackageList -import com.facebook.react.bridge.ReactMarker -import com.facebook.react.bridge.ReactMarkerConstants import com.facebook.react.common.annotations.UnstableReactNativeAPI import com.facebook.react.bridge.JSBundleLoader import com.facebook.react.defaults.DefaultComponentsRegistry @@ -69,12 +67,6 @@ class IsolatedTest { val view = surface.view!! - val renderLatch = CountDownLatch(1) - val markerListener = ReactMarker.MarkerListener { name, _, _ -> - if (name == ReactMarkerConstants.CONTENT_APPEARED) renderLatch.countDown() - } - ReactMarker.addListener(markerListener) - var detacher: WindowAttachment.Detacher? = null try { @@ -85,14 +77,9 @@ class IsolatedTest { detacher = WindowAttachment.dispatchAttach(view) ViewHelpers.setupView(view).setExactWidthPx(1080).setExactHeightPx(1920).layout() surface.start() - } - - assertTrue("Timed out waiting for first render", renderLatch.await(10, TimeUnit.SECONDS)) - instrumentation.runOnMainSync { - Screenshot.snap(view).record() + Screenshot.snap(view).setName("SimpleTestComponent").record() } } finally { - ReactMarker.removeListener(markerListener) instrumentation.runOnMainSync { surface.stop() detacher?.detach() From c0f1cca9ed41dde344ebfc278b1b99e2680a81f1 Mon Sep 17 00:00:00 2001 From: unknown Date: Wed, 8 Apr 2026 12:35:57 -0400 Subject: [PATCH 7/8] Wait for React render before snapping screenshot Use OnGlobalLayoutListener + CountDownLatch instead of taking the screenshot immediately after surface.start(). The latch releases once the surface view has children, which signals that React Native has rendered content. 30s timeout acts as a safeguard if rendering never completes. Co-Authored-By: Claude Sonnet 4.6 --- .../java/com/testapp/IsolatedTest.kt | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/android/app/src/androidTest/java/com/testapp/IsolatedTest.kt b/android/app/src/androidTest/java/com/testapp/IsolatedTest.kt index e4a7747..5fa7d34 100644 --- a/android/app/src/androidTest/java/com/testapp/IsolatedTest.kt +++ b/android/app/src/androidTest/java/com/testapp/IsolatedTest.kt @@ -3,6 +3,7 @@ package com.rnstorybookautoscreenshots import android.app.Application import android.graphics.Color import android.view.View +import android.view.ViewTreeObserver import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import com.facebook.react.PackageList @@ -68,6 +69,7 @@ class IsolatedTest { val view = surface.view!! var detacher: WindowAttachment.Detacher? = null + val renderLatch = CountDownLatch(1) try { instrumentation.runOnMainSync { @@ -76,7 +78,23 @@ class IsolatedTest { view.setBackgroundColor(Color.WHITE) detacher = WindowAttachment.dispatchAttach(view) ViewHelpers.setupView(view).setExactWidthPx(1080).setExactHeightPx(1920).layout() + + view.viewTreeObserver.addOnGlobalLayoutListener(object : ViewTreeObserver.OnGlobalLayoutListener { + override fun onGlobalLayout() { + if (view.childCount > 0) { + view.viewTreeObserver.removeOnGlobalLayoutListener(this) + renderLatch.countDown() + } + } + }) + surface.start() + } + + // Wait until React Native has rendered child views into the surface + renderLatch.await(30, TimeUnit.SECONDS) + + instrumentation.runOnMainSync { Screenshot.snap(view).setName("SimpleTestComponent").record() } } finally { From 52c03461846ee1a9ab8d2f0c052e0cc08d445555 Mon Sep 17 00:00:00 2001 From: unknown Date: Wed, 8 Apr 2026 14:31:12 -0400 Subject: [PATCH 8/8] Fix blank screenshot by polling for Fabric view mounting Replace window callbacks (OnGlobalLayoutListener) with direct polling of view.childCount on the main thread every 50ms. The listener never fired because WindowAttachment.dispatchAttach doesn't set up the full window infrastructure needed for layout callbacks. Polling works because Fabric does call addView() when mounting views, so childCount > 0 is a reliable signal that rendering has completed. Screenshot is taken immediately after children appear, giving a ~1.6s test time vs the previous 30s accidental sleep. Co-Authored-By: Claude Sonnet 4.6 --- .../java/com/testapp/IsolatedTest.kt | 28 ++++++++----------- 1 file changed, 12 insertions(+), 16 deletions(-) diff --git a/android/app/src/androidTest/java/com/testapp/IsolatedTest.kt b/android/app/src/androidTest/java/com/testapp/IsolatedTest.kt index 5fa7d34..75c4a24 100644 --- a/android/app/src/androidTest/java/com/testapp/IsolatedTest.kt +++ b/android/app/src/androidTest/java/com/testapp/IsolatedTest.kt @@ -3,7 +3,6 @@ package com.rnstorybookautoscreenshots import android.app.Application import android.graphics.Color import android.view.View -import android.view.ViewTreeObserver import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import com.facebook.react.PackageList @@ -24,8 +23,6 @@ import com.facebook.testing.screenshot.ViewHelpers import com.facebook.testing.screenshot.WindowAttachment import org.junit.Assert.*; import com.facebook.react.interfaces.* -import java.util.concurrent.CountDownLatch -import java.util.concurrent.TimeUnit @RunWith(AndroidJUnit4::class) class IsolatedTest { @@ -69,7 +66,7 @@ class IsolatedTest { val view = surface.view!! var detacher: WindowAttachment.Detacher? = null - val renderLatch = CountDownLatch(1) + var startTask: com.facebook.react.interfaces.TaskInterface? = null try { instrumentation.runOnMainSync { @@ -78,21 +75,20 @@ class IsolatedTest { view.setBackgroundColor(Color.WHITE) detacher = WindowAttachment.dispatchAttach(view) ViewHelpers.setupView(view).setExactWidthPx(1080).setExactHeightPx(1920).layout() + startTask = surface.start() + } - view.viewTreeObserver.addOnGlobalLayoutListener(object : ViewTreeObserver.OnGlobalLayoutListener { - override fun onGlobalLayout() { - if (view.childCount > 0) { - view.viewTreeObserver.removeOnGlobalLayoutListener(this) - renderLatch.countDown() - } - } - }) + // Wait for the surface start task to complete off the main thread. + assertGoodTask(startTask!!) - surface.start() + // Poll until Fabric has mounted child views into the surface. + val deadline = System.currentTimeMillis() + 30_000 + var hasChildren = false + while (!hasChildren && System.currentTimeMillis() < deadline) { + Thread.sleep(50) + instrumentation.runOnMainSync { hasChildren = view.childCount > 0 } } - - // Wait until React Native has rendered child views into the surface - renderLatch.await(30, TimeUnit.SECONDS) + assertTrue("Timed out waiting for React Native to render content", hasChildren) instrumentation.runOnMainSync { Screenshot.snap(view).setName("SimpleTestComponent").record()