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..3f6ad20 --- /dev/null +++ b/android/app/src/androidTest/java/com/testapp/ComponentSurfaceTest.kt @@ -0,0 +1,45 @@ +package com.rnstorybookautoscreenshots + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ComponentSurfaceTest { + + @Test + fun screenshotAll() { + ReactHostFixture.screenshotAll(listOf( + // Button story variants + "Button_Primary", + "Button_Secondary", + "Button_Large", + "Button_Small", + + // Header story variants + "Header_LoggedIn", + "Header_LoggedOut", + + // MyFeature story variants + "MyFeature_Initial", + "MyFeature_WithClicks", + "MyFeature_ManyClicks", + + // SwitchFeature story variants + "SwitchFeature_Off", + "SwitchFeature_On", + + // TimerFeature story variants + "TimerFeature_Initial", + "TimerFeature_Running", + "TimerFeature_Paused", + "TimerFeature_LongDuration", + + // CoinFlipFeature story variants + "CoinFlipFeature_Default", + "CoinFlipFeature_Heads", + "CoinFlipFeature_Tails", + "CoinFlipFeature_ManyFlips", + )) + } +} 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..d712b20 --- /dev/null +++ b/android/app/src/androidTest/java/com/testapp/ReactHostFixture.kt @@ -0,0 +1,106 @@ +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.interfaces.fabric.ReactSurface +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 + } + + /** + * Starts all [componentNames] surfaces simultaneously, waits for each JS component + * to call ScreenshotHelper.takeScreenshot, then tears everything down. + * + * All surfaces are attached and started in a single runOnMainSync, so the JS thread + * queues work for all of them at once instead of waiting for each test to finish + * before the next begins. + */ + @OptIn(UnstableReactNativeAPI::class) + fun screenshotAll(componentNames: List) { + val instrumentation = InstrumentationRegistry.getInstrumentation() + val latch = CountDownLatch(componentNames.size) + ScreenshotHelperModule.sharedLatch = latch + + val surfaces = mutableListOf() + val detachers = mutableListOf() + + componentNames.forEach { name -> + val context = ContextThemeWrapper(instrumentation.targetContext, R.style.AppTheme) + val surface = reactHost.createSurface(context, name, null) + assertNotNull("No view for $name", surface.view) + ScreenshotHelperModule.pendingViews[name] = WeakReference(surface.view!!) + surfaces.add(surface) + detachers.add(null) + } + + try { + instrumentation.runOnMainSync { + surfaces.forEachIndexed { i, surface -> + val view = surface.view!! + view.setLayerType(View.LAYER_TYPE_SOFTWARE, null) + view.setBackgroundColor(Color.WHITE) + detachers[i] = WindowAttachment.dispatchAttach(view) + ViewHelpers.setupView(view).setExactWidthPx(1080).setExactHeightPx(1920).layout() + surface.start() + } + } + + assertTrue( + "Timed out waiting for all screenshots: $componentNames", + latch.await(60, TimeUnit.SECONDS) + ) + } finally { + ScreenshotHelperModule.pendingViews.clear() + ScreenshotHelperModule.sharedLatch = null + instrumentation.runOnMainSync { + surfaces.forEach { it.stop() } + detachers.forEach { it?.detach() } + } + } + } + + fun screenshotComponent(componentName: String) = screenshotAll(listOf(componentName)) +} 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..f809f2b --- /dev/null +++ b/android/app/src/androidTest/java/com/testapp/ScreenshotHelperModule.kt @@ -0,0 +1,43 @@ +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.ConcurrentHashMap +import java.util.concurrent.CountDownLatch + +/** + * Native module called from JS when a component has finished rendering. + * + * [pendingViews] maps component name → view to screenshot. + * [sharedLatch] is counted down once per component that calls back, + * so the test thread can await all of them concurrently. + */ +class ScreenshotHelperModule(reactContext: ReactApplicationContext) + : ReactContextBaseJavaModule(reactContext) { + + companion object { + val pendingViews = ConcurrentHashMap>() + var sharedLatch: CountDownLatch? = null + } + + override fun getName() = "ScreenshotHelper" + + @ReactMethod + fun takeScreenshot(name: String) { + val view = pendingViews[name]?.get() + if (view == null) { + sharedLatch?.countDown() + return + } + val instrumentation = InstrumentationRegistry.getInstrumentation() + instrumentation.runOnMainSync { + Screenshot.snap(view).setName(name).record() + } + sharedLatch?.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 = () =>