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 546b3ce..bd8c474 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,8 +4,10 @@ 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.ViewGroup import android.view.WindowManager import androidx.test.platform.app.InstrumentationRegistry import androidx.test.rule.GrantPermissionRule @@ -16,7 +18,8 @@ import com.facebook.testing.screenshot.ViewHelpers import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test -import java.io.File +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit /** * Base screenshot test that automatically discovers and tests all Storybook stories. @@ -28,17 +31,23 @@ 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. + * Experiment: js-driven-story-loop + * --------------------------------- + * JS drives the entire story sequence. StoryRenderer renders all stories one at a + * time in a for loop, calling notifyStoryReady(storyId) after each render and + * waiting for the Promise to resolve before moving to the next story. + * + * The test thread loops reactively: wait for notifyStoryReady → take screenshot → + * resolve the Promise → repeat until allStoriesDone(). + * + * No events, no isBlockingSynchronousMethod, no manifest pre-loading step. */ abstract class BaseStoryScreenshotTest { companion object { private const val TAG = "BaseStoryScreenshotTest" private const val DEFAULT_LOAD_TIMEOUT_MS = 5000L - private const val DEFAULT_BOOTSTRAP_TIMEOUT_MS = 10000L - private const val BOOTSTRAP_STORY_NAME = "__bootstrap__" + private const val DEFAULT_TOTAL_TIMEOUT_MS = 300_000L // 5 minutes for all stories private const val SCREEN_WIDTH_PX = 1080 private const val SCREEN_HEIGHT_PX = 1920 @@ -58,110 +67,76 @@ abstract class BaseStoryScreenshotTest { open fun getMainComponentName(): String = "StoryRenderer" /** - * Override to customize the React Native load timeout per story. + * Override to customize the per-story screenshot timeout. * Default is 5000ms. */ open fun getLoadTimeoutMs(): Long = DEFAULT_LOAD_TIMEOUT_MS /** - * Override to customize the timeout for manifest bootstrap. - * Default is 10000ms. - */ - open fun getBootstrapTimeoutMs(): Long = DEFAULT_BOOTSTRAP_TIMEOUT_MS - - /** - * Override to filter which stories should be screenshotted. - * Return true to include the story, false to skip it. - * Default includes all stories. - */ - open fun shouldScreenshotStory(storyInfo: StoryInfo): Boolean = true - - /** - * 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. + * Screenshots all stories. JS tells us which story to screenshot and when — + * the test thread just reacts to notifyStoryReady() calls. */ @Test fun screenshotAllStories() { - val context = InstrumentationRegistry.getInstrumentation().targetContext - val externalDir = context.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) } + val instrumentation = InstrumentationRegistry.getInstrumentation() - Log.d(TAG, "Found ${allStories.size} stories, ${stories.size} after filtering") - assertTrue("No stories found in manifest", stories.isNotEmpty()) + StorybookRegistry.prepareForRun() - 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) + mountSurface { view -> + // React to stories as JS renders them, until allStoriesDone() is called. + while (true) { + StorybookRegistry.prepareForNextStory() + val storyId = StorybookRegistry.awaitStoryReady(getLoadTimeoutMs()) + + if (storyId == null) { + // allStoriesDone() was called — JS has finished. + Log.d(TAG, "All stories done") + break + } + + try { + // Two frames so Fabric's native view mutations are fully applied. + waitTwoFrames() + + val screenshotName = storyId.replace("--", "_") + instrumentation.runOnMainSync { + // view.draw(canvas) can't capture children that have hardware display + // lists. Force the entire tree to software so draw() sees all content. + setLayerTypeSoftwareRecursively(view) + Screenshot.snap(view).setName(screenshotName).record() + } + Log.d(TAG, "Screenshot captured: $screenshotName") + } catch (e: Exception) { + failures.add("$storyId: ${e.message}") + Log.e(TAG, "Failed to screenshot story: $storyId", e) + } finally { + // Resolve the notifyStoryReady() Promise so JS can render the next story. + StorybookRegistry.resolveCurrentStory() + } } } - Log.d(TAG, "Screenshot results: $successCount passed, $failureCount failed") - if (failures.isNotEmpty()) { - Log.e(TAG, "Failed stories:\n${failures.joinToString("\n")}") - } - + Log.d(TAG, "${failures.size} stories failed") 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})") - - 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) { - waitForManifestFile(manifestFile) - } - Log.d(TAG, "Bootstrap complete") - } - /** - * Renders the given story name into a view, calls [onRendered] with that view, - * then tears down. Handles both old arch (ReactRootView) and new arch (ReactSurface). + * Mounts a single React surface for the whole test run, calls [onMounted] with + * the view, then tears down. Handles both old arch (ReactRootView) and new arch + * (ReactSurface). No props are passed — JS drives itself. */ - private fun renderStory(storyName: String, onRendered: (view: View) -> Unit) { + private fun mountSurface(onMounted: (view: View) -> Unit) { val instrumentation = InstrumentationRegistry.getInstrumentation() val app = instrumentation.targetContext.applicationContext as ReactApplication - val props = Bundle().apply { putString("storyName", storyName) } 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 @@ -169,7 +144,7 @@ abstract class BaseStoryScreenshotTest { val surface = reactHost.createSurface( context, getMainComponentName(), - props + Bundle() ) val view = surface.view @@ -186,19 +161,18 @@ abstract class BaseStoryScreenshotTest { ) 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 { + onMounted(view) + } finally { + instrumentation.runOnMainSync { + surface.stop() + wm.removeView(view) + } } } else { // Old arch: ReactRootView + ReactInstanceManager (deprecated API). @@ -208,34 +182,40 @@ abstract class BaseStoryScreenshotTest { @Suppress("DEPRECATION") val reactInstanceManager = app.reactNativeHost.reactInstanceManager - // ReactRootView.startReactApplication() checks isOnUiThread() internally. instrumentation.runOnMainSync { - rootView.startReactApplication(reactInstanceManager, getMainComponentName(), props) + rootView.startReactApplication(reactInstanceManager, getMainComponentName(), Bundle()) } - // 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 { + onMounted(rootView) + } finally { + instrumentation.runOnMainSync { rootView.unmountReactApplication() } + } } } - private fun waitForManifestFile(manifestFile: File) { - val deadline = System.currentTimeMillis() + getBootstrapTimeoutMs() - while (!manifestFile.exists() && System.currentTimeMillis() < deadline) { - Thread.sleep(100) + private fun setLayerTypeSoftwareRecursively(view: View) { + view.setLayerType(View.LAYER_TYPE_SOFTWARE, null) + if (view is ViewGroup) { + for (i in 0 until view.childCount) { + setLayerTypeSoftwareRecursively(view.getChildAt(i)) + } } - if (!manifestFile.exists()) { - throw IllegalStateException( - "Manifest file did not appear within ${getBootstrapTimeoutMs()}ms. " + - "Make sure configure(view) is called in your app and the StoryRenderer is registered." - ) + } + + private fun waitTwoFrames() { + val instrumentation = InstrumentationRegistry.getInstrumentation() + repeat(2) { + val latch = CountDownLatch(1) + instrumentation.runOnMainSync { + Choreographer.getInstance().postFrameCallback { latch.countDown() } + } + latch.await(1000, TimeUnit.MILLISECONDS) } } } 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..a6b9390 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 @@ -1,6 +1,7 @@ package com.rnstorybookautoscreenshots import android.util.Log +import com.facebook.react.bridge.Promise import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.bridge.ReactContextBaseJavaModule import com.facebook.react.bridge.ReactMethod @@ -10,14 +11,23 @@ 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: + * Native module with three responsibilities: * - Receives the story list from JS and writes it to disk for test discovery. - * - Synchronises the test thread with JS rendering via a CountDownLatch. + * - Receives notifyStoryReady(storyId) from JS and holds the Promise until the + * test thread has taken the screenshot and calls resolveCurrentStory(). + * - Receives allStoriesDone() from JS to signal the test thread to exit. + * + * Communication flow: + * JS thread → notifyStoryReady(storyId, promise) → storyReadyLatch.countDown() + * Test thread ← awaitStoryReady() ← storyReadyLatch + * Test thread → takes screenshot → resolveCurrentStory() → promise.resolve() + * JS thread ← promise resolves → renders next story + * ... + * JS thread → allStoriesDone() → allDoneLatch.countDown() + * Test thread ← awaitAllDone() ← allDoneLatch */ - class StorybookRegistry(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) { companion object { @@ -25,19 +35,51 @@ class StorybookRegistry(reactContext: ReactApplicationContext) : ReactContextBas const val STORIES_FILE_NAME = "storybook_stories.json" @Volatile private var storyReadyLatch: CountDownLatch? = null + @Volatile private var allDoneLatch: CountDownLatch? = null + @Volatile private var pendingPromise: Promise? = null + @Volatile private var pendingStoryId: String? = null + @Volatile private var isDone = false + + /** + * Call once before mounting the surface to set up the all-done latch. + */ + fun prepareForRun() { + allDoneLatch = CountDownLatch(1) + isDone = false + } /** - * Call before rendering each story. Creates a fresh latch for [awaitStoryReady]. + * Call before each story to create a fresh latch for [awaitStoryReady]. */ fun prepareForNextStory() { storyReadyLatch = CountDownLatch(1) + isDone = false + pendingStoryId = null } /** - * Blocks until JS signals the story is rendered, or the timeout elapses. + * Blocks until JS calls notifyStoryReady() or allStoriesDone(), or the timeout elapses. + * Returns the story ID, or null if allStoriesDone() was called (or timeout). */ - fun awaitStoryReady(timeoutMs: Long) { + fun awaitStoryReady(timeoutMs: Long): String? { storyReadyLatch?.await(timeoutMs, TimeUnit.MILLISECONDS) + return if (isDone) null else pendingStoryId + } + + /** + * Resolves the Promise that notifyStoryReady() is holding. + * Call this after taking the screenshot to let JS proceed to the next story. + */ + fun resolveCurrentStory() { + pendingPromise?.resolve(null) + pendingPromise = null + } + + /** + * Blocks until JS calls allStoriesDone(), or the timeout elapses. + */ + fun awaitAllDone(timeoutMs: Long) { + allDoneLatch?.await(timeoutMs, TimeUnit.MILLISECONDS) } /** @@ -72,21 +114,29 @@ class StorybookRegistry(reactContext: ReactApplicationContext) : ReactContextBas override fun getName(): String = "StorybookRegistry" /** - * Called from JS when a story has finished rendering (or errored). - * Releases the latch that screenshotStory() is waiting on. + * Called from JS after a story has been rendered and committed to the view. + * Stores the Promise (so the test thread can resolve it after the screenshot) + * and releases [awaitStoryReady] so the test thread knows it can proceed. */ @ReactMethod - fun notifyStoryReady() { + fun notifyStoryReady(storyId: String, promise: Promise) { + pendingStoryId = storyId + pendingPromise = promise storyReadyLatch?.countDown() } - fun loadStory(storyName: String) { - reactApplicationContext - .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java) - ?.emit("loadStory", storyName) + /** + * Called from JS after all stories have been rendered. + * Releases [awaitAllDone] so the test thread can exit and unmount the surface. + */ + @ReactMethod + fun allStoriesDone() { + isDone = true + // Release any thread waiting on awaitStoryReady (in case it's still waiting). + storyReadyLatch?.countDown() + allDoneLatch?.countDown() } - /** * Called from JS to register the list of available stories. * Writes to external files directory for test access. diff --git a/packages/rn-storybook-auto-screenshots/src/StoryRenderer.tsx b/packages/rn-storybook-auto-screenshots/src/StoryRenderer.tsx index c45f540..dc2cd38 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 React, { useEffect, useRef, useState } from 'react'; +import { View, Text, StyleSheet, NativeModules } from 'react-native'; const { StorybookRegistry } = NativeModules; @@ -19,10 +18,6 @@ export function configure(view: any) { storybookView = view; } -type StoryRendererProps = { - storyName?: string; -}; - /** * Register all available stories with the native module. * This allows the Android test to discover stories automatically. @@ -50,83 +45,121 @@ export function registerStoriesWithNative() { } /** - * Renders individual Storybook stories for screenshot testing. - * Uses Storybook's actual rendering pipeline. + * Builds the prepared story mapping without waiting for _preview.ready(). * - * @param storyName - Format: "ComponentName/StoryName" (e.g., "MyFeature/Initial") + * createPreparedStoryMapping() waits on storeInitializationPromise, which only + * resolves when the Storybook UI renders. In our test scenario the UI never + * renders, so it hangs. We bypass it by going directly to storyStoreValue, + * which is set synchronously during app startup before our surface mounts. + * The importFn is async (importPath) => importMap[importPath] — the map is + * eagerly loaded, so each loadStory call resolves in the next microtask. */ -export function StoryRenderer({ storyName = 'MyFeature/Initial' }: StoryRendererProps) { +async function buildPreparedStories() { + if (Object.keys(storybookView._idToPrepared).length > 0) { + return; // already populated (e.g. by Storybook's own updateView()) + } + + const storyStore = storybookView._preview.storyStoreValue; + if (storyStore) { + await Promise.all( + Object.keys(storybookView._storyIndex.entries).map(async (storyId: string) => { + storybookView._idToPrepared[storyId] = await storyStore.loadStory({ storyId }); + }) + ); + } else { + // storyStore not yet set — fall back to the standard path. + await storybookView.createPreparedStoryMapping(); + } +} + +/** + * Renders all Storybook stories for screenshot testing, one at a time. + * + * JS drives the entire sequence — no events from native, no blocking sync calls. + * + * Flow: + * 1. Mount → register stories → buildPreparedStories() (sync-ish, no ready() wait) + * 2. setCurrentStoryId(stories[0].id) → React renders + * 3. useEffect([storyContent, error]) fires after commit → notifyStoryReady(id) + * → native takes screenshot, resolves Promise → advance to next story + * 4. Repeat until all stories done → allStoriesDone() + */ +export function StoryRenderer() { + const [currentStoryId, setCurrentStoryId] = useState(null); 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); - }); - return () => sub.remove(); - }, []); - // Notify native after React's commit phase so the test thread can proceed to screenshot. + // Story list and current position, set once during init. + const storiesRef = useRef>([]); + const indexRef = useRef(-1); // -1 = not started yet + + // After each story is committed to the view, notify native directly — + // no intermediate Promise, same pattern as the original notifyStoryReady call. + // When native resolves (screenshot taken), advance to the next story. useEffect(() => { - if (!loading) { - StorybookRegistry.notifyStoryReady(); - } - }, [loading]); + if (indexRef.current < 0) return; // init hasn't set the first story yet + + const story = storiesRef.current[indexRef.current]; + if (!story) return; + + StorybookRegistry.notifyStoryReady(story.id).then(() => { + const next = indexRef.current + 1; + indexRef.current = next; + if (next < storiesRef.current.length) { + setCurrentStoryId(storiesRef.current[next].id); + } else { + StorybookRegistry.allStoriesDone(); + } + }); + }, [storyContent, error]); + // Render the prepared story synchronously — _idToPrepared is built before + // the first setCurrentStoryId call, so no async work needed here. useEffect(() => { - async function renderStory() { - try { - if (!storybookView) { - setError('Storybook not configured. Call configure(view) first.'); - setLoading(false); - return; - } - - // Register all stories with native module for test discovery. - // _storyIndex is set synchronously by Storybook's start(), so this - // doesn't need to wait for createPreparedStoryMapping(). - registerStoriesWithNative(); - - const storyId = storyNameToId(storyName); - - // Lazily populate _idToPrepared — createPreparedStoryMapping() is async. - if (!storybookView._idToPrepared || Object.keys(storybookView._idToPrepared).length === 0) { - await storybookView.createPreparedStoryMapping(); - } - - const preparedStory = storybookView._idToPrepared[storyId]; - - if (!preparedStory) { - const availableStories = Object.keys(storybookView._idToPrepared || {}).join(', '); - setError(`Story "${storyId}" not found. Available: ${availableStories}`); - setLoading(false); - return; - } - - const storyContext = storybookView._preview.getStoryContext(preparedStory); - const { unboundStoryFn: StoryComponent } = preparedStory; - const rendered = ; - - setStoryContent(rendered); - setLoading(false); - } catch (e) { - setError(`Error rendering story: ${e}`); - setLoading(false); + if (currentStoryId === null) return; + + try { + const preparedStory = storybookView._idToPrepared[currentStoryId]; + if (!preparedStory) { + setError(`Story "${currentStoryId}" not found`); + setStoryContent(null); + return; } + const storyContext = storybookView._preview.getStoryContext(preparedStory); + const { unboundStoryFn: StoryComponent } = preparedStory; + setError(null); + setStoryContent(); + } catch (e) { + setError(`Error rendering story: ${e}`); + setStoryContent(null); } + }, [currentStoryId]); - renderStory(); - }, [storyName]); + // Bootstrap: build prepared stories once, then kick off the story loop. + useEffect(() => { + async function init() { + if (!storybookView) { + setError('Storybook not configured. Call configure(view) first.'); + return; + } - if (loading) { - return ( - - Loading story... - - ); - } + registerStoriesWithNative(); + await buildPreparedStories(); + + const stories = getAllStories(); + storiesRef.current = stories; + + if (stories.length === 0) { + StorybookRegistry.allStoriesDone(); + return; + } + + indexRef.current = 0; + setCurrentStoryId(stories[0].id); + } + + init(); + }, []); if (error) { return (