Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
experiment: eager story prep at configure() time
Kicks off loadStory() for all stories immediately in configure(), using
_preview.storyStoreValue directly (bypasses _preview.ready() which only
resolves when the Storybook UI renders — never in a test run).

By the time StoryRenderer mounts, _idToPrepared is already fully
populated, so the render effect is synchronous (no await in the hot
path). Combined with the JS-driven Promise handshake from the previous
experiment, this removes all async work between "set story" and
"notify native ready".

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
  • Loading branch information
EmilioBejasa and claude committed Mar 27, 2026
commit 985e9946924274a34f2a45d98b877d2ac776d705
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@ package com.rnstorybookautoscreenshots
import android.Manifest
import android.graphics.PixelFormat
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.view.Choreographer
import android.view.ContextThemeWrapper
import android.view.View
import android.view.WindowManager
Expand All @@ -16,10 +19,10 @@ 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

/**
* Base screenshot test that automatically discovers and tests all Storybook stories.
* Base screenshot test that automatically renders and screenshots all Storybook stories.
*
* Extend this class in your app's androidTest directory:
*
Expand All @@ -28,17 +31,16 @@ 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.
* A single React surface is mounted for the entire test run. JS drives the story
* loop — rendering each story and calling notifyStoryReady() after React commits.
* The test thread screenshots and then resolves the JS Promise to advance the loop.
* When all stories are done JS calls allStoriesDone() and the test exits.
*/
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 SCREEN_WIDTH_PX = 1080
private const val SCREEN_HEIGHT_PX = 1920
Expand All @@ -58,102 +60,84 @@ abstract class BaseStoryScreenshotTest {
open fun getMainComponentName(): String = "StoryRenderer"

/**
* Override to customize the React Native load timeout per story.
* Override to customize the per-story 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.
* Override to skip specific stories.
* Return true to include the story, false to skip it.
* Default includes all stories.
*/
open fun shouldScreenshotStory(storyInfo: StoryInfo): Boolean = true
open fun shouldScreenshotStory(storyId: String): 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 Storybook stories.
*
* Mounts a single StoryRenderer surface. JS iterates through all stories,
* calling notifyStoryReady() after each commit. The test thread screenshots
* and resolves the Promise to let JS advance.
*/
@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)
mountSurface { view ->
runStoryLoop(view)
}
}

val allStories = StorybookRegistry.getStoriesFromFile(externalDir!!)
val stories = allStories.filter { shouldScreenshotStory(it) }
private fun runStoryLoop(view: View) {
val instrumentation = InstrumentationRegistry.getInstrumentation()
val failures = mutableListOf<String>()
var successCount = 0

Log.d(TAG, "Found ${allStories.size} stories, ${stories.size} after filtering")
assertTrue("No stories found in manifest", stories.isNotEmpty())
while (true) {
StorybookRegistry.prepareForNextStory()
val storyId = StorybookRegistry.awaitStoryReady(getLoadTimeoutMs()) ?: break

var successCount = 0
var failureCount = 0
val failures = mutableListOf<String>()
if (!shouldScreenshotStory(storyId)) {
Log.d(TAG, "Skipping story: $storyId")
StorybookRegistry.resolveCurrentStory()
continue
}

for (story in stories) {
Log.d(TAG, "Screenshotting: $storyId")
try {
screenshotStory(story)
// Wait for Fabric to apply native mutations before snapping.
waitTwoFrames()
val screenshotName = storyId.replace("--", "_")
instrumentation.runOnMainSync {
Screenshot.snap(view).setName(screenshotName).record()
}
Log.d(TAG, "Screenshot captured: $screenshotName")
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)
failures.add("$storyId: ${e.message}")
Log.e(TAG, "Failed to screenshot story: $storyId", e)
} finally {
StorybookRegistry.resolveCurrentStory()
}
}

Log.d(TAG, "Screenshot results: $successCount passed, $failureCount failed")
Log.d(TAG, "Screenshot results: $successCount passed, ${failures.size} failed")
if (failures.isNotEmpty()) {
Log.e(TAG, "Failed stories:\n${failures.joinToString("\n")}")
}

assertTrue("No stories were screenshotted", successCount > 0)
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 the StoryRenderer surface, calls [onMounted] with the view, then tears down.
* Handles both new arch (ReactHost/ReactSurface) and old arch (ReactRootView).
*/
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) {
Expand All @@ -169,7 +153,7 @@ abstract class BaseStoryScreenshotTest {
val surface = reactHost.createSurface(
context,
getMainComponentName(),
props
Bundle()
)

val view = surface.view
Expand All @@ -194,7 +178,7 @@ abstract class BaseStoryScreenshotTest {
surface.start()
}

onRendered(view)
onMounted(view)

instrumentation.runOnMainSync {
surface.stop()
Expand All @@ -210,7 +194,7 @@ abstract class BaseStoryScreenshotTest {

// 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
Expand All @@ -220,22 +204,26 @@ abstract class BaseStoryScreenshotTest {
.setExactHeightPx(SCREEN_HEIGHT_PX)
.layout()

onRendered(rootView)
onMounted(rootView)

instrumentation.runOnMainSync { rootView.unmountReactApplication() }
}
}

private fun waitForManifestFile(manifestFile: File) {
val deadline = System.currentTimeMillis() + getBootstrapTimeoutMs()
while (!manifestFile.exists() && System.currentTimeMillis() < deadline) {
Thread.sleep(100)
}
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."
)
/**
* Waits for two Choreographer frames on the main thread.
*
* After useEffect fires (React commit), Fabric still needs to apply its
* native mutations in the next frame(s). Waiting two frames ensures the
* shadow tree is fully flushed to native views before we screenshot.
*/
private fun waitTwoFrames() {
repeat(2) {
val latch = CountDownLatch(1)
Handler(Looper.getMainLooper()).post {
Choreographer.getInstance().postFrameCallback { latch.countDown() }
}
latch.await()
}
}
}
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -10,39 +11,59 @@ 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.
* - Synchronises the test thread with JS rendering via a CountDownLatch.
* - Synchronises the test thread with JS rendering via a CountDownLatch/Promise handshake.
*
* Protocol per story:
* 1. Test calls prepareForNextStory() — arms a fresh latch.
* 2. JS calls notifyStoryReady(id, promise) — stores promise, counts down latch.
* 3. Test calls awaitStoryReady(timeout) — blocks until latch fires, returns story id.
* 4. Test screenshots, then calls resolveCurrentStory() — resolves the JS promise.
* 5. JS advances to the next story (repeat from 1), or calls allStoriesDone().
*/

class StorybookRegistry(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) {

companion object {
private const val TAG = "StorybookRegistry"
const val STORIES_FILE_NAME = "storybook_stories.json"

@Volatile private var storyReadyLatch: CountDownLatch? = null
@Volatile private var pendingStoryId: String? = null
@Volatile private var pendingPromise: Promise? = null
@Volatile private var isDone = false

/**
* Call before rendering each story. Creates a fresh latch for [awaitStoryReady].
* Reset state and arm a fresh latch. Call before each story.
*/
fun prepareForNextStory() {
storyReadyLatch = CountDownLatch(1)
}

/**
* 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 all stories are done or the timeout elapsed.
*/
fun awaitStoryReady(timeoutMs: Long) {
fun awaitStoryReady(timeoutMs: Long): String? {
storyReadyLatch?.await(timeoutMs, TimeUnit.MILLISECONDS)
return if (isDone) null else pendingStoryId
}

/**
* Resolves the Promise that JS is awaiting, letting it advance to the next story.
* Call this after the screenshot has been captured.
*/
fun resolveCurrentStory() {
pendingPromise?.resolve(null)
pendingPromise = null
pendingStoryId = null
}

/**
* Read stories from the manifest file.
* Used by screenshot tests to get list of all stories.
* Used by screenshot tests to get the list of all stories.
*/
fun getStoriesFromFile(storageDir: File): List<StoryInfo> {
val file = File(storageDir, STORIES_FILE_NAME)
Expand Down Expand Up @@ -72,21 +93,26 @@ 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 React commits a story render.
* Stores the promise (resolved later by resolveCurrentStory) and unblocks the test thread.
*/
@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 when all stories have been rendered.
* Unblocks awaitStoryReady so the test loop can exit.
*/
@ReactMethod
fun allStoriesDone() {
isDone = true
storyReadyLatch?.countDown()
}


/**
* Called from JS to register the list of available stories.
* Writes to external files directory for test access.
Expand Down
Loading
Loading