diff --git a/packages/rn-storybook-auto-screenshots/android/src/main/java/com/rnstorybookautoscreenshots/BaseStoryScreenshotTest.kt b/packages/rn-storybook-auto-screenshots/android/src/main/java/com/rnstorybookautoscreenshots/BaseStoryScreenshotTest.kt index 7d29c39..466b141 100644 --- a/packages/rn-storybook-auto-screenshots/android/src/main/java/com/rnstorybookautoscreenshots/BaseStoryScreenshotTest.kt +++ b/packages/rn-storybook-auto-screenshots/android/src/main/java/com/rnstorybookautoscreenshots/BaseStoryScreenshotTest.kt @@ -4,10 +4,13 @@ import android.Manifest import android.graphics.PixelFormat import android.os.Bundle import android.util.Log +import android.view.Choreographer import android.view.ContextThemeWrapper import android.view.View import android.view.WindowManager import androidx.test.platform.app.InstrumentationRegistry +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit import androidx.test.rule.GrantPermissionRule import com.facebook.react.ReactApplication import com.facebook.react.ReactRootView @@ -28,9 +31,8 @@ import java.io.File * class StoryScreenshotTest : BaseStoryScreenshotTest() * ``` * - * This test automatically bootstraps the story manifest if it doesn't exist, - * then creates a screenshot for each story. No manual test methods needed - - * just add stories to Storybook and they get tested automatically. + * This test mounts a single surface once, bootstraps the story manifest, then + * drives each story via loadStory() events rather than remounting per story. */ abstract class BaseStoryScreenshotTest { @@ -78,72 +80,78 @@ abstract class BaseStoryScreenshotTest { /** * Screenshots all stories found in the manifest. - * Each story gets its own screenshot named after its ID. - * If the manifest doesn't exist, it will be bootstrapped automatically. + * + * Mounts a single ReactSurface for the entire run. The first render + * (storyName="__bootstrap__") triggers registerStoriesWithNative() and + * createPreparedStoryMapping() on the JS side. Subsequent stories are loaded + * via loadStory() events fired on the main thread, each waiting on a fresh + * CountDownLatch that notifyStoryReady() releases. */ @Test fun screenshotAllStories() { - val context = InstrumentationRegistry.getInstrumentation().targetContext - val externalDir = context.getExternalFilesDir("screenshots") + val instrumentation = InstrumentationRegistry.getInstrumentation() + val externalDir = instrumentation.targetContext.getExternalFilesDir("screenshots")!! val manifestFile = File(externalDir, StorybookRegistry.STORIES_FILE_NAME) - if (!manifestFile.exists()) { - Log.d(TAG, "Manifest not found, bootstrapping...") - bootstrapManifest(manifestFile) - } - - val allStories = StorybookRegistry.getStoriesFromFile(externalDir!!) - val stories = allStories.filter { shouldScreenshotStory(it) } - - Log.d(TAG, "Found ${allStories.size} stories, ${stories.size} after filtering") - assertTrue("No stories found in manifest", stories.isNotEmpty()) - - var successCount = 0 - var failureCount = 0 - val failures = mutableListOf() - - for (story in stories) { - try { - screenshotStory(story) - successCount++ - } catch (e: Exception) { - failureCount++ - val errorMsg = "${story.title}/${story.name}: ${e.message}" - failures.add(errorMsg) - Log.e(TAG, "Failed to screenshot story: $errorMsg", e) - } - } - - Log.d(TAG, "Screenshot results: $successCount passed, $failureCount failed") - if (failures.isNotEmpty()) { - Log.e(TAG, "Failed stories:\n${failures.joinToString("\n")}") - } - - assertTrue( - "Some stories failed to screenshot: ${failures.joinToString(", ")}", - failures.isEmpty() - ) - } - - private fun screenshotStory(storyInfo: StoryInfo) { - val storyName = storyInfo.toStoryName() - Log.d(TAG, "Screenshotting: $storyName (id: ${storyInfo.id})") - + // Prepare a latch for the bootstrap render. StorybookRegistry.prepareForNextStory() - renderStory(storyName) { view -> - StorybookRegistry.awaitStoryReady(getLoadTimeoutMs()) - val screenshotName = storyInfo.id.replace("--", "_") - Screenshot.snap(view).setName(screenshotName).record() - Log.d(TAG, "Screenshot captured: $screenshotName") - } - } - private fun bootstrapManifest(manifestFile: File) { - Log.d(TAG, "Launching StoryRenderer to generate manifest...") - renderStory(BOOTSTRAP_STORY_NAME) { + // Mount a single surface for the whole test. The bootstrap render will: + // 1. Call registerStoriesWithNative() → write the manifest to disk. + // 2. Call createPreparedStoryMapping() → populate _idToPrepared. + // 3. Call notifyStoryReady() (via error path — __bootstrap__ is not a real story). + renderStory(BOOTSTRAP_STORY_NAME) { view -> waitForManifestFile(manifestFile) + StorybookRegistry.awaitStoryReady(getBootstrapTimeoutMs()) + Log.d(TAG, "Bootstrap complete, surface ready") + + val allStories = StorybookRegistry.getStoriesFromFile(externalDir) + val stories = allStories.filter { shouldScreenshotStory(it) } + Log.d(TAG, "Found ${allStories.size} stories, ${stories.size} after filtering") + assertTrue("No stories found in manifest", stories.isNotEmpty()) + + var successCount = 0 + val failures = mutableListOf() + + for (story in stories) { + try { + StorybookRegistry.prepareForNextStory() + instrumentation.runOnMainSync { + StorybookRegistry.loadStory(story.id) + } + StorybookRegistry.awaitStoryReady(getLoadTimeoutMs()) + + // Wait two frames so Fabric's native view mutations are fully applied + // before we snap the software-layer bitmap. + repeat(2) { + val frameLatch = CountDownLatch(1) + instrumentation.runOnMainSync { + Choreographer.getInstance().postFrameCallback { frameLatch.countDown() } + } + frameLatch.await(1000, TimeUnit.MILLISECONDS) + } + + val screenshotName = story.id.replace("--", "_") + instrumentation.runOnMainSync { + Screenshot.snap(view).setName(screenshotName).record() + } + Log.d(TAG, "Screenshot captured: $screenshotName") + successCount++ + } catch (e: Exception) { + failures.add("${story.title}/${story.name}: ${e.message}") + Log.e(TAG, "Failed to screenshot story: ${story.id}", e) + } + } + + Log.d(TAG, "Screenshot results: $successCount passed, ${failures.size} failed") + if (failures.isNotEmpty()) { + Log.e(TAG, "Failed stories:\n${failures.joinToString("\n")}") + } + assertTrue( + "Some stories failed to screenshot: ${failures.joinToString(", ")}", + failures.isEmpty() + ) } - Log.d(TAG, "Bootstrap complete") } /** @@ -158,10 +166,6 @@ abstract class BaseStoryScreenshotTest { val reactHost = app.reactHost if (reactHost != null) { // New arch (Fabric/bridgeless): ReactHost + ReactSurface. - // Fabric won't commit its render tree until the surface's host view is parented - // to a real Window. Test processes don't have an Activity window, so we attach - // via WindowManager using TYPE_APPLICATION_OVERLAY (requires SYSTEM_ALERT_WINDOW). - // Wrap with the app theme so AppCompat widgets (e.g. Switch) resolve styled attrs. val context = ContextThemeWrapper( instrumentation.targetContext, instrumentation.targetContext.applicationInfo.theme @@ -184,25 +188,22 @@ abstract class BaseStoryScreenshotTest { WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE, PixelFormat.TRANSLUCENT ).apply { - // alpha=0 lets the compositor skip this window entirely while still - // satisfying Fabric's requirement that the surface be attached to a Window. alpha = 0f } instrumentation.runOnMainSync { - // Force software rendering so Screenshot.snap() can capture via draw(canvas). - // WindowManager views are hardware-accelerated by default; GPU content is - // invisible to a software canvas. view.setLayerType(View.LAYER_TYPE_SOFTWARE, null) wm.addView(view, params) surface.start() } - onRendered(view) - - instrumentation.runOnMainSync { - surface.stop() - wm.removeView(view) + try { + onRendered(view) + } finally { + instrumentation.runOnMainSync { + surface.stop() + wm.removeView(view) + } } } else { // Old arch: ReactRootView + ReactInstanceManager (deprecated API). @@ -212,21 +213,20 @@ abstract class BaseStoryScreenshotTest { @Suppress("DEPRECATION") val reactInstanceManager = app.reactNativeHost.reactInstanceManager - // ReactRootView.startReactApplication() checks isOnUiThread() internally. instrumentation.runOnMainSync { rootView.startReactApplication(reactInstanceManager, getMainComponentName(), props) } - // setupView().layout() calls measure()+layout() at the fixed dimensions, which - // triggers onMeasure() → attachToReactInstanceManager() on the ReactRootView. ViewHelpers.setupView(rootView) .setExactWidthPx(SCREEN_WIDTH_PX) .setExactHeightPx(SCREEN_HEIGHT_PX) .layout() - onRendered(rootView) - - instrumentation.runOnMainSync { rootView.unmountReactApplication() } + try { + onRendered(rootView) + } finally { + instrumentation.runOnMainSync { rootView.unmountReactApplication() } + } } } diff --git a/packages/rn-storybook-auto-screenshots/android/src/main/java/com/rnstorybookautoscreenshots/StorybookRegistry.kt b/packages/rn-storybook-auto-screenshots/android/src/main/java/com/rnstorybookautoscreenshots/StorybookRegistry.kt index 1dfb0c9..e6f18ef 100644 --- a/packages/rn-storybook-auto-screenshots/android/src/main/java/com/rnstorybookautoscreenshots/StorybookRegistry.kt +++ b/packages/rn-storybook-auto-screenshots/android/src/main/java/com/rnstorybookautoscreenshots/StorybookRegistry.kt @@ -10,8 +10,6 @@ import org.json.JSONObject import java.io.File import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit -import com.facebook.react.modules.core.DeviceEventManagerModule - /** * Native module with two responsibilities: * - Receives the story list from JS and writes it to disk for test discovery. @@ -25,6 +23,12 @@ class StorybookRegistry(reactContext: ReactApplicationContext) : ReactContextBas const val STORIES_FILE_NAME = "storybook_stories.json" @Volatile private var storyReadyLatch: CountDownLatch? = null + @Volatile private var instance: StorybookRegistry? = null + + /** Emit a loadStory event to JS, switching the active story without remounting. */ + fun loadStory(storyName: String) { + instance?.loadStory(storyName) + } /** * Call before rendering each story. Creates a fresh latch for [awaitStoryReady]. @@ -69,6 +73,10 @@ class StorybookRegistry(reactContext: ReactApplicationContext) : ReactContextBas } } + init { + instance = this + } + override fun getName(): String = "StorybookRegistry" /** @@ -81,9 +89,7 @@ class StorybookRegistry(reactContext: ReactApplicationContext) : ReactContextBas } fun loadStory(storyName: String) { - reactApplicationContext - .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java) - ?.emit("loadStory", storyName) + reactApplicationContext.emitDeviceEvent("loadStory", storyName) } diff --git a/packages/rn-storybook-auto-screenshots/src/StoryRenderer.tsx b/packages/rn-storybook-auto-screenshots/src/StoryRenderer.tsx index c45f540..b625a07 100644 --- a/packages/rn-storybook-auto-screenshots/src/StoryRenderer.tsx +++ b/packages/rn-storybook-auto-screenshots/src/StoryRenderer.tsx @@ -1,6 +1,5 @@ import React, { useEffect, useState } from 'react'; -import { View, Text, StyleSheet, NativeModules, NativeEventEmitter } from 'react-native'; -import { storyNameToId } from './utils'; +import { View, Text, StyleSheet, NativeModules, DeviceEventEmitter } from 'react-native'; const { StorybookRegistry } = NativeModules; @@ -53,18 +52,20 @@ export function registerStoriesWithNative() { * Renders individual Storybook stories for screenshot testing. * Uses Storybook's actual rendering pipeline. * - * @param storyName - Format: "ComponentName/StoryName" (e.g., "MyFeature/Initial") + * @param storyName - Storybook story ID (e.g., "myfeature--initial") */ -export function StoryRenderer({ storyName = 'MyFeature/Initial' }: StoryRendererProps) { +export function StoryRenderer({ storyName = 'myfeature--initial' }: StoryRendererProps) { + const [activeStoryName, setActiveStoryName] = useState(storyName); const [storyContent, setStoryContent] = useState(null); const [error, setError] = useState(null); const [loading, setLoading] = useState(true); - useEffect (() => { - const emitter = new NativeEventEmitter(NativeModules.StorybookRegistry); - const sub = emitter.addListener('loadStory', (name: string) => { - console.log('loadStory event received:', name); - }); + // Switch stories without remounting — native calls loadStory() to trigger a new render. + useEffect(() => { + const sub = DeviceEventEmitter.addListener('loadStory', (name: string) => { + setLoading(true); + setActiveStoryName(name); + }); return () => sub.remove(); }, []); @@ -89,7 +90,10 @@ export function StoryRenderer({ storyName = 'MyFeature/Initial' }: StoryRenderer // doesn't need to wait for createPreparedStoryMapping(). registerStoriesWithNative(); - const storyId = storyNameToId(storyName); + // activeStoryName IS the story ID (e.g. "example-button--primary"). + // Native passes the ID directly so hierarchical titles like "Example/Button" + // are handled correctly without any string conversion. + const storyId = activeStoryName; // Lazily populate _idToPrepared — createPreparedStoryMapping() is async. if (!storybookView._idToPrepared || Object.keys(storybookView._idToPrepared).length === 0) { @@ -118,7 +122,7 @@ export function StoryRenderer({ storyName = 'MyFeature/Initial' }: StoryRenderer } renderStory(); - }, [storyName]); + }, [activeStoryName]); if (loading) { return (