From 3fc0609b9750def8021c35f06501ddcc7ce1bfee Mon Sep 17 00:00:00 2001 From: unknown Date: Tue, 7 Apr 2026 13:55:49 -0400 Subject: [PATCH 1/2] JS-driven screenshot approach via ScreenshotHelper native module Instead of waiting for ReactMarker.CONTENT_APPEARED from the native side, each component calls NativeModules.ScreenshotHelper.takeScreenshot(name) from useEffect via requestAnimationFrame. The native module takes the screenshot and releases a CountDownLatch, unblocking the test thread. This inverts control: the JS render cycle determines when the screenshot is taken, rather than native heuristics about when React is "done". Co-Authored-By: Claude Sonnet 4.6 --- .../java/com/testapp/ComponentSurfaceTest.kt | 51 +++++++++ .../java/com/testapp/IsolatedTest.kt | 43 +------- .../java/com/testapp/ReactHostFixture.kt | 96 ++++++++++++++++ .../com/testapp/ScreenshotHelperModule.kt | 42 +++++++ .../com/testapp/ScreenshotHelperPackage.kt | 14 +++ index.js | 104 +++++++++++++++++- 6 files changed, 307 insertions(+), 43 deletions(-) create mode 100644 android/app/src/androidTest/java/com/testapp/ComponentSurfaceTest.kt create mode 100644 android/app/src/androidTest/java/com/testapp/ReactHostFixture.kt create mode 100644 android/app/src/androidTest/java/com/testapp/ScreenshotHelperModule.kt create mode 100644 android/app/src/androidTest/java/com/testapp/ScreenshotHelperPackage.kt diff --git a/android/app/src/androidTest/java/com/testapp/ComponentSurfaceTest.kt b/android/app/src/androidTest/java/com/testapp/ComponentSurfaceTest.kt new file mode 100644 index 0000000..e07f457 --- /dev/null +++ b/android/app/src/androidTest/java/com/testapp/ComponentSurfaceTest.kt @@ -0,0 +1,51 @@ +package com.rnstorybookautoscreenshots + +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.Parameterized + +@RunWith(Parameterized::class) +class ComponentSurfaceTest(private val componentName: String) { + + companion object { + @JvmStatic + @Parameterized.Parameters(name = "{0}") + fun components() = listOf( + // Button story variants + arrayOf("Button_Primary"), + arrayOf("Button_Secondary"), + arrayOf("Button_Large"), + arrayOf("Button_Small"), + + // Header story variants + arrayOf("Header_LoggedIn"), + arrayOf("Header_LoggedOut"), + + // MyFeature story variants + arrayOf("MyFeature_Initial"), + arrayOf("MyFeature_WithClicks"), + arrayOf("MyFeature_ManyClicks"), + + // SwitchFeature story variants + arrayOf("SwitchFeature_Off"), + arrayOf("SwitchFeature_On"), + + // TimerFeature story variants + arrayOf("TimerFeature_Initial"), + arrayOf("TimerFeature_Running"), + arrayOf("TimerFeature_Paused"), + arrayOf("TimerFeature_LongDuration"), + + // CoinFlipFeature story variants + arrayOf("CoinFlipFeature_Default"), + arrayOf("CoinFlipFeature_Heads"), + arrayOf("CoinFlipFeature_Tails"), + arrayOf("CoinFlipFeature_ManyFlips"), + ) + } + + @Test + fun screenshot() { + ReactHostFixture.screenshotComponent(componentName) + } +} diff --git a/android/app/src/androidTest/java/com/testapp/IsolatedTest.kt b/android/app/src/androidTest/java/com/testapp/IsolatedTest.kt index 59c03d0..567cf76 100644 --- a/android/app/src/androidTest/java/com/testapp/IsolatedTest.kt +++ b/android/app/src/androidTest/java/com/testapp/IsolatedTest.kt @@ -1,55 +1,14 @@ package com.rnstorybookautoscreenshots import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.platform.app.InstrumentationRegistry -import com.testapp.MainApplication -import junit.framework.TestCase.assertNotNull -import junit.framework.TestCase.assertTrue 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.* @RunWith(AndroidJUnit4::class) class IsolatedTest { - @Test - fun simpleTest() { - assertTrue(true) - } @Test fun constructViewTest() { - val context = InstrumentationRegistry.getInstrumentation().targetContext - val app = context.applicationContext as MainApplication - val surface = app.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()) - - - assertNotNull(surface.view) - - ViewHelpers.setupView(surface.view!!) - .setExactHeightPx(1000) - .setExactWidthPx(1000) - .layout() - - - val ti = surface.start() - assertGoodTask(ti) - - Screenshot.snap(surface.view!!) - .record() + ReactHostFixture.screenshotComponent("ConstructViewTest") } } - -fun assertGoodTask(ti : TaskInterface) { - ti.waitForCompletion() - assertFalse(ti.isFaulted()) - assertTrue(ti.isCompleted()) -} diff --git a/android/app/src/androidTest/java/com/testapp/ReactHostFixture.kt b/android/app/src/androidTest/java/com/testapp/ReactHostFixture.kt new file mode 100644 index 0000000..eb8177d --- /dev/null +++ b/android/app/src/androidTest/java/com/testapp/ReactHostFixture.kt @@ -0,0 +1,96 @@ +package com.rnstorybookautoscreenshots + +import android.app.Application +import android.graphics.Color +import android.view.ContextThemeWrapper +import android.view.View +import androidx.test.platform.app.InstrumentationRegistry +import com.facebook.react.PackageList +import com.facebook.react.bridge.JSBundleLoader +import com.facebook.react.common.annotations.UnstableReactNativeAPI +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 com.facebook.testing.screenshot.ViewHelpers +import com.facebook.testing.screenshot.WindowAttachment +import com.testapp.R +import com.testapp.ScreenshotHelperModule +import com.testapp.ScreenshotHelperPackage +import junit.framework.TestCase.assertNotNull +import org.junit.Assert.assertTrue +import java.lang.ref.WeakReference +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit + +@OptIn(UnstableReactNativeAPI::class) +object ReactHostFixture { + val reactHost: ReactHostImpl by lazy { + val instrumentation = InstrumentationRegistry.getInstrumentation() + val context = instrumentation.targetContext + 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 + listOf(ScreenshotHelperPackage()), + jsRuntimeFactory = HermesInstance(), + turboModuleManagerDelegateBuilder = DefaultTurboModuleManagerDelegate.Builder(), + exceptionHandler = { throw it }, + ) + val componentFactory = ComponentFactory() + DefaultComponentsRegistry.register(componentFactory) + val host = ReactHostImpl(context, delegate, componentFactory, false, false) + instrumentation.runOnMainSync { host.onHostResume(null) } + host + } + + /** + * Creates a surface for [componentName], waits for the JS component to call + * ScreenshotHelper.takeScreenshot (via useEffect), then tears down. + * + * The screenshot is taken inside the native module on the JS callback, so the + * timing is driven by the JS render cycle rather than native heuristics. + */ + @OptIn(UnstableReactNativeAPI::class) + fun screenshotComponent(componentName: String) { + val instrumentation = InstrumentationRegistry.getInstrumentation() + val context = ContextThemeWrapper(instrumentation.targetContext, R.style.AppTheme) + + val surface = reactHost.createSurface(context, componentName, null) + assertNotNull(surface.view) + val view = surface.view!! + + val latch = CountDownLatch(1) + ScreenshotHelperModule.pendingView = WeakReference(view) + ScreenshotHelperModule.pendingLatch = latch + + var detacher: WindowAttachment.Detacher? = null + + try { + instrumentation.runOnMainSync { + view.setLayerType(View.LAYER_TYPE_SOFTWARE, null) + view.setBackgroundColor(Color.WHITE) + detacher = WindowAttachment.dispatchAttach(view) + ViewHelpers.setupView(view).setExactWidthPx(1080).setExactHeightPx(1920).layout() + surface.start() + } + + assertTrue( + "Timed out waiting for JS to call takeScreenshot: $componentName", + latch.await(10, TimeUnit.SECONDS) + ) + } finally { + ScreenshotHelperModule.pendingView = null + ScreenshotHelperModule.pendingLatch = null + instrumentation.runOnMainSync { + surface.stop() + detacher?.detach() + } + } + } +} diff --git a/android/app/src/androidTest/java/com/testapp/ScreenshotHelperModule.kt b/android/app/src/androidTest/java/com/testapp/ScreenshotHelperModule.kt new file mode 100644 index 0000000..f1ba3d6 --- /dev/null +++ b/android/app/src/androidTest/java/com/testapp/ScreenshotHelperModule.kt @@ -0,0 +1,42 @@ +package com.testapp + +import android.view.View +import androidx.test.platform.app.InstrumentationRegistry +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReactContextBaseJavaModule +import com.facebook.react.bridge.ReactMethod +import com.facebook.testing.screenshot.Screenshot +import java.lang.ref.WeakReference +import java.util.concurrent.CountDownLatch + +/** + * Native module called from JS when a component has finished rendering. + * + * The test sets [pendingView] and [pendingLatch] before starting each surface. + * When JS calls [takeScreenshot], the module snaps the view and releases the latch + * so the test thread can unblock and clean up. + */ +class ScreenshotHelperModule(reactContext: ReactApplicationContext) + : ReactContextBaseJavaModule(reactContext) { + + companion object { + var pendingView: WeakReference? = null + var pendingLatch: CountDownLatch? = null + } + + override fun getName() = "ScreenshotHelper" + + @ReactMethod + fun takeScreenshot(name: String) { + val view = pendingView?.get() + if (view == null) { + pendingLatch?.countDown() + return + } + val instrumentation = InstrumentationRegistry.getInstrumentation() + instrumentation.runOnMainSync { + Screenshot.snap(view).setName(name).record() + } + pendingLatch?.countDown() + } +} diff --git a/android/app/src/androidTest/java/com/testapp/ScreenshotHelperPackage.kt b/android/app/src/androidTest/java/com/testapp/ScreenshotHelperPackage.kt new file mode 100644 index 0000000..e53332b --- /dev/null +++ b/android/app/src/androidTest/java/com/testapp/ScreenshotHelperPackage.kt @@ -0,0 +1,14 @@ +package com.testapp + +import com.facebook.react.ReactPackage +import com.facebook.react.bridge.NativeModule +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.uimanager.ViewManager + +class ScreenshotHelperPackage : ReactPackage { + override fun createNativeModules(reactContext: ReactApplicationContext): List = + listOf(ScreenshotHelperModule(reactContext)) + + override fun createViewManagers(reactContext: ReactApplicationContext): List> = + emptyList() +} diff --git a/index.js b/index.js index 05c1681..5d8a3cf 100644 --- a/index.js +++ b/index.js @@ -2,7 +2,8 @@ * @format */ -import { AppRegistry, View, Text } from 'react-native'; +import React, { useEffect, useState } from 'react'; +import { AppRegistry, NativeModules, View, Text } from 'react-native'; import { name as appName } from './app.json'; // Configure and register StoryRenderer for screenshot tests @@ -17,6 +18,107 @@ const { view } = require('./.rnstorybook/storybook.requires'); const SimpleTestComponent = () => Hello; AppRegistry.registerComponent('SimpleTestComponent', () => SimpleTestComponent); + +// Imports for story variants +const MyFeature = require('./MyFeature').default; +const SwitchFeature = require('./SwitchFeature').default; +const TimerFeature = require('./TimerFeature').default; +const CoinFlipFeature = require('./CoinFlipFeature').default; +const { Button } = require('./.rnstorybook/stories/Button'); +const { Header } = require('./.rnstorybook/stories/Header'); + +const noop = () => {}; + +/** + * Higher-order component that wraps a story component and calls + * ScreenshotHelper.takeScreenshot from the JS side once rendered. + * requestAnimationFrame defers by one frame so native layout has settled. + */ +const withAutoScreenshot = (name, InnerComponent) => { + const Wrapper = () => { + useEffect(() => { + requestAnimationFrame(() => { + NativeModules.ScreenshotHelper.takeScreenshot(name); + }); + }, []); + return ; + }; + return Wrapper; +}; + +// IsolatedTest component +const ConstructViewTest = () => Hello; +AppRegistry.registerComponent('ConstructViewTest', () => withAutoScreenshot('ConstructViewTest', ConstructViewTest)); + +// --- Button story variants --- + +const ButtonPrimary = () =>