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..0aa0c4f 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 @@ -17,6 +19,8 @@ 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,9 +32,18 @@ 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: sync-story-blocking + * -------------------------------- + * Instead of emitting loadStory events, the test thread pushes story IDs into a + * LinkedBlockingQueue. JS pulls them via the blocking synchronous awaitNextStory() + * call, which blocks the JS thread while native takes the screenshot. This inverts + * the control flow: JS pulls from native rather than native pushing to JS. + * + * A single React surface is mounted for the entire run. The JS component loops: + * awaitNextStory() → render → notifyStoryReady() → awaitNextStory() → … + * + * The test thread drives the loop by pushing story IDs, waiting for notifyStoryReady, + * taking a screenshot, then pushing the next ID. A null push signals JS to stop. */ abstract class BaseStoryScreenshotTest { @@ -38,7 +51,7 @@ abstract class BaseStoryScreenshotTest { 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 BOOTSTRAP_STORY_ID = "__bootstrap__" private const val SCREEN_WIDTH_PX = 1080 private const val SCREEN_HEIGHT_PX = 1920 @@ -78,90 +91,86 @@ 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 React surface for the entire run. JS blocks itself via + * awaitNextStory() between renders; the test thread drives the sequence by + * pushing story IDs and waiting for notifyStoryReady() after each render. */ @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() + // Push the bootstrap marker before mounting so JS unblocks immediately on mount. + StorybookRegistry.prepareForNextStory() + StorybookRegistry.pushStory(BOOTSTRAP_STORY_ID) - 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 -> + // Wait for JS to register stories and write the manifest. + waitForManifestFile(manifestFile) + StorybookRegistry.awaitStoryReady(getBootstrapTimeoutMs()) + Log.d(TAG, "Bootstrap complete") + + 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()) + + val failures = mutableListOf() + + for (story in stories) { + try { + // Push the next story ID — JS unblocks from awaitNextStory() and renders it. + StorybookRegistry.prepareForNextStory() + StorybookRegistry.pushStory(story.id) + + // Wait for JS to signal the story is rendered. + StorybookRegistry.awaitStoryReady(getLoadTimeoutMs()) + + // Two frames so Fabric's native view mutations are fully applied. + waitTwoFrames() + + val screenshotName = story.id.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("${story.title}/${story.name}: ${e.message}") + Log.e(TAG, "Failed to screenshot story: ${story.id}", 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})") - 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") - } - } + // Signal JS that there are no more stories. + StorybookRegistry.pushStory(null) - private fun bootstrapManifest(manifestFile: File) { - Log.d(TAG, "Launching StoryRenderer to generate manifest...") - renderStory(BOOTSTRAP_STORY_NAME) { - waitForManifestFile(manifestFile) + Log.d(TAG, "Screenshot results: ${stories.size - failures.size} 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") } /** - * 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 storyName prop is passed — JS drives itself via awaitNextStory(). */ - 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 +178,7 @@ abstract class BaseStoryScreenshotTest { val surface = reactHost.createSurface( context, getMainComponentName(), - props + Bundle() ) val view = surface.view @@ -186,19 +195,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,21 +216,45 @@ 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) + try { + onMounted(rootView) + } finally { + instrumentation.runOnMainSync { rootView.unmountReactApplication() } + } + } + } - instrumentation.runOnMainSync { rootView.unmountReactApplication() } + /** + * Recursively sets LAYER_TYPE_SOFTWARE on every view in the tree. + * view.draw(canvas) skips children that have hardware display lists; + * forcing software rendering on all nodes ensures the full tree is captured. + */ + 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)) + } + } + } + + 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..3fd1737 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 @@ -3,29 +3,53 @@ package com.rnstorybookautoscreenshots import android.util.Log import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.bridge.ReactContextBaseJavaModule +import com.facebook.react.bridge.Promise import com.facebook.react.bridge.ReactMethod import com.facebook.react.bridge.ReadableArray import org.json.JSONArray import org.json.JSONObject import java.io.File import java.util.concurrent.CountDownLatch +import java.util.concurrent.LinkedBlockingQueue 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. + * - Provides awaitNextStory(), a Promise-based pull that lets JS wait for the + * next story ID from the test runner rather than receiving events. * - Synchronises the test thread with JS rendering via a CountDownLatch. + * + * Communication flow: + * Test thread → pushStory(id) → storyQueue + * JS thread ← await awaitNextStory() ← background thread ← storyQueue + * JS thread → notifyStoryReady() + * Test thread ← awaitStoryReady() ← storyReadyLatch */ - class StorybookRegistry(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) { companion object { private const val TAG = "StorybookRegistry" const val STORIES_FILE_NAME = "storybook_stories.json" + // Capacity 1 so pushStory() blocks until JS has consumed the current entry, + // giving natural back-pressure between the test runner and JS. + // LinkedBlockingQueue does not allow null, so we use a sentinel to signal done. + private const val DONE_SENTINEL = "__done__" + private val storyQueue = LinkedBlockingQueue(1) + @Volatile private var storyReadyLatch: CountDownLatch? = null + /** + * Called by the test thread to enqueue the next story for JS to render. + * Pass null to signal that all stories are done. + * + * Blocks until JS has consumed the previous entry (queue capacity = 1). + */ + fun pushStory(storyId: String?) { + storyQueue.put(storyId ?: DONE_SENTINEL) + } + /** * Call before rendering each story. Creates a fresh latch for [awaitStoryReady]. */ @@ -71,21 +95,30 @@ class StorybookRegistry(reactContext: ReactApplicationContext) : ReactContextBas override fun getName(): String = "StorybookRegistry" + /** + * Promise-based pull — resolves with the next story ID once the test runner + * pushes one via [pushStory], or null when the test runner signals done. + * + * The blocking queue.poll() runs on a background thread so the React Native + * JS thread is never blocked (replaces the deprecated isBlockingSynchronousMethod). + */ + @ReactMethod + fun awaitNextStory(promise: Promise) { + Thread { + val raw = storyQueue.poll(30, TimeUnit.SECONDS) + promise.resolve(if (raw == DONE_SENTINEL) null else raw) + }.start() + } + /** * Called from JS when a story has finished rendering (or errored). - * Releases the latch that screenshotStory() is waiting on. + * Releases the latch that the test thread is waiting on. */ @ReactMethod fun notifyStoryReady() { storyReadyLatch?.countDown() } - fun loadStory(storyName: String) { - reactApplicationContext - .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java) - ?.emit("loadStory", storyName) - } - /** * Called from JS to register the list of available stories. diff --git a/packages/rn-storybook-auto-screenshots/src/StoryRenderer.tsx b/packages/rn-storybook-auto-screenshots/src/StoryRenderer.tsx index c45f540..e649094 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 } 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,120 @@ 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(). + * + * 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. + */ +async function buildPreparedStories() { + if (Object.keys(storybookView._idToPrepared).length > 0) { + return; // already populated + } + + 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 { + await storybookView.createPreparedStoryMapping(); + } +} + +/** + * Renders Storybook stories for screenshot testing. * - * @param storyName - Format: "ComponentName/StoryName" (e.g., "MyFeature/Initial") + * Native controls the story order by pushing IDs into a queue. JS pulls each + * ID by awaiting awaitNextStory() (Promise resolved on a background thread), + * renders the story, notifies native via notifyStoryReady(), then awaits the + * next ID. This inverts the event-push model: JS pulls rather than native pushing. + * + * Flow: + * 1. Mount → register stories → await awaitNextStory() (waits for queue) + * 2. Test thread pushes story ID → Promise resolves → JS renders + * 3. After commit: notifyStoryReady() releases native latch + * 4. await awaitNextStory() again — native takes screenshot, then pushes next ID + * 5. When native pushes null, the loop ends */ -export function StoryRenderer({ storyName = 'MyFeature/Initial' }: StoryRendererProps) { +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. + // Bootstrap: register stories then wait for the first story ID from native. useEffect(() => { - if (!loading) { - StorybookRegistry.notifyStoryReady(); - } - }, [loading]); + async function init() { + if (!storybookView) { + setError('Storybook not configured. Call configure(view) first.'); + return; + } - useEffect(() => { - async function renderStory() { - try { - if (!storybookView) { - setError('Storybook not configured. Call configure(view) first.'); - setLoading(false); - return; - } + registerStoriesWithNative(); - // 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(); + // awaitNextStory() resolves on a background thread once the test runner + // pushes the first story ID via pushStory(). + const firstId: string | null = await StorybookRegistry.awaitNextStory(); + if (firstId !== null) { + setCurrentStoryId(firstId); + } + } - const storyId = storyNameToId(storyName); + init(); + }, []); - // Lazily populate _idToPrepared — createPreparedStoryMapping() is async. - if (!storybookView._idToPrepared || Object.keys(storybookView._idToPrepared).length === 0) { - await storybookView.createPreparedStoryMapping(); - } + // Render the story for the current storyId. + // buildPreparedStories() is called lazily here to handle the first real story + // (bootstrap ID "__bootstrap__" won't have a prepared entry — that's expected). + useEffect(() => { + if (currentStoryId === null) return; - const preparedStory = storybookView._idToPrepared[storyId]; + async function renderStory() { + try { + await buildPreparedStories(); + const preparedStory = storybookView._idToPrepared[currentStoryId!]; if (!preparedStory) { - const availableStories = Object.keys(storybookView._idToPrepared || {}).join(', '); - setError(`Story "${storyId}" not found. Available: ${availableStories}`); - setLoading(false); + const available = Object.keys(storybookView._idToPrepared || {}).join(', '); + setError(`Story "${currentStoryId}" not found. Available: ${available}`); + setStoryContent(null); return; } const storyContext = storybookView._preview.getStoryContext(preparedStory); const { unboundStoryFn: StoryComponent } = preparedStory; - const rendered = ; - - setStoryContent(rendered); - setLoading(false); + setError(null); + setStoryContent(); } catch (e) { setError(`Error rendering story: ${e}`); - setLoading(false); + setStoryContent(null); } } renderStory(); - }, [storyName]); + }, [currentStoryId]); - if (loading) { - return ( - - Loading story... - - ); - } + // After each story commit: signal native, then await the next story ID. + // notifyStoryReady() releases the latch the test thread is waiting on. + // awaitNextStory() blocks (on a background thread) until native pushes the + // next ID — giving native time to take the screenshot first. + useEffect(() => { + if (currentStoryId === null) return; + + async function advance() { + StorybookRegistry.notifyStoryReady(); + const nextId: string | null = await StorybookRegistry.awaitNextStory(); + if (nextId !== null) { + setCurrentStoryId(nextId); + } + // else: null means all stories are done — stop here. + } + + advance(); + }, [storyContent, error]); if (error) { return (