diff --git a/packages/rn-storybook-auto-screenshots/android/src/main/java/com/rnstorybookautoscreenshots/BaseStoryRendererActivity.kt b/packages/rn-storybook-auto-screenshots/android/src/main/java/com/rnstorybookautoscreenshots/BaseStoryRendererActivity.kt index 5cb13b4..a98afa3 100644 --- a/packages/rn-storybook-auto-screenshots/android/src/main/java/com/rnstorybookautoscreenshots/BaseStoryRendererActivity.kt +++ b/packages/rn-storybook-auto-screenshots/android/src/main/java/com/rnstorybookautoscreenshots/BaseStoryRendererActivity.kt @@ -3,6 +3,7 @@ package com.rnstorybookautoscreenshots import android.os.Bundle import com.facebook.react.ReactActivity import com.facebook.react.ReactActivityDelegate +import com.facebook.react.ReactApplication import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled import com.facebook.react.defaults.DefaultReactActivityDelegate @@ -32,6 +33,12 @@ open class BaseStoryRendererActivity : ReactActivity() { /** * Returns the instance of the ReactActivityDelegate with custom launch options. */ + fun loadStory(storyName: String) { + (application as? ReactApplication)?.reactHost?.currentReactContext + ?.getNativeModule(StorybookRegistry::class.java) + ?.loadStory(storyName) + } + override fun createReactActivityDelegate(): ReactActivityDelegate { return object : DefaultReactActivityDelegate(this, mainComponentName, fabricEnabled) { override fun getLaunchOptions(): Bundle? { 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 7d20c23..8e75b52 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 @@ -3,14 +3,18 @@ package com.rnstorybookautoscreenshots import android.Manifest import android.content.Intent import android.util.Log +import android.view.ViewTreeObserver import androidx.test.core.app.ActivityScenario import androidx.test.core.app.ApplicationProvider +import androidx.test.platform.app.InstrumentationRegistry import androidx.test.rule.GrantPermissionRule import com.facebook.testing.screenshot.Screenshot 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. @@ -85,36 +89,58 @@ abstract class BaseStoryScreenshotTest { val externalDir = context.getExternalFilesDir("screenshots") val manifestFile = File(externalDir, StorybookRegistry.STORIES_FILE_NAME) - // Bootstrap manifest if it doesn't exist + val intent = Intent( + ApplicationProvider.getApplicationContext(), + getStoryRendererActivityClass() + ).apply { + putExtra(BaseStoryRendererActivity.EXTRA_STORY_NAME, BOOTSTRAP_STORY_NAME) + } + + // Launch once — RN initializes and registers all stories + StorybookRegistry.prepareForNextStory() + val scenario = ActivityScenario.launch(intent) + StorybookRegistry.awaitStoryReady(getBootstrapTimeoutMs()) + if (!manifestFile.exists()) { - Log.d(TAG, "Manifest not found, bootstrapping...") - bootstrapManifest(manifestFile) + waitForManifestFile(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++ + val storyName = story.toStoryName() + Log.d(TAG, "Screenshotting: $storyName (id: ${story.id})") + + StorybookRegistry.prepareForNextStory() + scenario.onActivity { activity -> activity.loadStory(storyName) } + StorybookRegistry.awaitStoryReady(getLoadTimeoutMs()) + InstrumentationRegistry.getInstrumentation().waitForIdleSync() + waitForNextDraw(scenario) + + scenario.onActivity { activity -> + val screenshotName = story.id.replace("--", "_") + Screenshot.snap(activity.window.decorView.rootView) + .setName(screenshotName) + .record() + Log.d(TAG, "Screenshot captured: $screenshotName") + } } 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") + scenario.close() + + 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")}") @@ -126,61 +152,28 @@ abstract class BaseStoryScreenshotTest { ) } - private fun screenshotStory(storyInfo: StoryInfo) { - val storyName = storyInfo.toStoryName() - Log.d(TAG, "Screenshotting: $storyName (id: ${storyInfo.id})") - - val intent = Intent( - ApplicationProvider.getApplicationContext(), - getStoryRendererActivityClass() - ).apply { - putExtra(BaseStoryRendererActivity.EXTRA_STORY_NAME, storyName) - } - - StorybookRegistry.prepareForNextStory() - val scenario = ActivityScenario.launch(intent) - - // Wait for JS to signal the story has finished rendering, up to the timeout. - StorybookRegistry.awaitStoryReady(getLoadTimeoutMs()) - - scenario.onActivity { activity -> - val rootView = activity.window.decorView.rootView - - // Use story ID as screenshot name (replace -- with _ for filesystem compatibility) - val screenshotName = storyInfo.id.replace("--", "_") - - // Capture screenshot using screenshot-tests-for-android - // In record mode: saves baseline images - // In verify mode: compares against baselines - Screenshot.snap(rootView) - .setName(screenshotName) - .record() - - Log.d(TAG, "Screenshot captured: $screenshotName") - } - - scenario.close() - } - /** - * Bootstraps the story manifest by launching StoryRendererActivity. - * This allows React Native to initialize and register all stories. + * Waits for the next draw pass on the decor view. + * + * waitForIdleSync() drains the main thread message queue but view drawing + * is triggered by VSYNC via Choreographer, not the message queue. This + * method waits for an OnDrawListener callback, which fires after the next + * frame is actually drawn — ensuring the screenshot captures painted content. */ - private fun bootstrapManifest(manifestFile: File) { - Log.d(TAG, "Launching StoryRenderer to generate manifest...") - - val intent = Intent( - ApplicationProvider.getApplicationContext(), - getStoryRendererActivityClass() - ).apply { - putExtra(BaseStoryRendererActivity.EXTRA_STORY_NAME, BOOTSTRAP_STORY_NAME) + private fun waitForNextDraw(scenario: ActivityScenario) { + val latch = CountDownLatch(1) + scenario.onActivity { activity -> + val decorView = activity.window.decorView + decorView.viewTreeObserver.addOnDrawListener(object : ViewTreeObserver.OnDrawListener { + override fun onDraw() { + latch.countDown() + decorView.post { + decorView.viewTreeObserver.removeOnDrawListener(this) + } + } + }) } - - val scenario = ActivityScenario.launch(intent) - waitForManifestFile(manifestFile) - scenario.close() - - Log.d(TAG, "Bootstrap complete") + latch.await(2000, 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 0215680..26f7f81 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,11 +10,13 @@ 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 that receives the story list from Storybook JS side. * Stories are written to a file that screenshot tests can read. */ + class StorybookRegistry(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) { companion object { @@ -77,6 +79,13 @@ class StorybookRegistry(reactContext: ReactApplicationContext) : ReactContextBas 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. * 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 d322044..2e5ab89 100644 --- a/packages/rn-storybook-auto-screenshots/src/StoryRenderer.tsx +++ b/packages/rn-storybook-auto-screenshots/src/StoryRenderer.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useState } from 'react'; -import { View, Text, StyleSheet, NativeModules } from 'react-native'; +import { View, Text, StyleSheet, NativeModules, NativeEventEmitter } from 'react-native'; import { storyNameToId } from './utils'; const { StorybookRegistry } = NativeModules; @@ -57,15 +57,28 @@ export function registerStoriesWithNative() { * @param storyName - Format: "ComponentName/StoryName" (e.g., "MyFeature/Initial") */ export function StoryRenderer({ storyName = 'MyFeature/Initial' }: StoryRendererProps) { + const [currentStoryName, setCurrentStoryName] = 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) => { + setCurrentStoryName(name); + setLoading(true); + }); + return () => sub.remove(); + }, []); + // Notify native when the story has finished rendering (or errored). - // This runs after React commits the update, so the native views are up to date. + // requestAnimationFrame defers until after the next frame is painted, + // ensuring native view mutations are flushed before the screenshot is taken. useEffect(() => { if (!loading) { - StorybookRegistry.notifyStoryReady(); + requestAnimationFrame(() => { + StorybookRegistry.notifyStoryReady(); + }); } }, [loading]); @@ -83,7 +96,14 @@ export function StoryRenderer({ storyName = 'MyFeature/Initial' }: StoryRenderer // doesn't need to wait for createPreparedStoryMapping(). registerStoriesWithNative(); - const storyId = storyNameToId(storyName); + // Bootstrap story has no '/' — its only purpose is to trigger story + // registration above. Nothing to render. + if (!currentStoryName.includes('/')) { + setLoading(false); + return; + } + + const storyId = storyNameToId(currentStoryName); // Wait for Storybook to be ready and prepare story mappings if (!storybookView._idToPrepared || Object.keys(storybookView._idToPrepared).length === 0) { @@ -94,7 +114,7 @@ export function StoryRenderer({ storyName = 'MyFeature/Initial' }: StoryRenderer if (!preparedStory) { const availableStories = Object.keys(storybookView._idToPrepared || {}).join(', '); - setError(`Story "${storyId}" not found. Available: ${availableStories}`); + setError(`Story "${currentStoryName}" (id: ${storyId}) not found. Available: ${availableStories}`); setLoading(false); return; } @@ -115,7 +135,7 @@ export function StoryRenderer({ storyName = 'MyFeature/Initial' }: StoryRenderer } renderStory(); - }, [storyName]); + }, [currentStoryName]); if (loading) { return (