Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
189a418
Replace boilerplate README with library documentation
EmilioBejasa Feb 25, 2026
324207f
Merge branch 'main' of https://github.com/screenshotbot/react-native-…
EmilioBejasa Feb 25, 2026
85e0c11
Merge branch 'main' of https://github.com/screenshotbot/react-native-…
EmilioBejasa Feb 26, 2026
c0533a4
Merge branch 'main' of https://github.com/screenshotbot/react-native-…
EmilioBejasa Mar 3, 2026
3a4392a
Merge branch 'main' of https://github.com/screenshotbot/react-native-…
EmilioBejasa Mar 9, 2026
f7c41aa
Merge branch 'main' of https://github.com/screenshotbot/react-native-…
EmilioBejasa Mar 9, 2026
8569c63
Merge branch 'main' of https://github.com/screenshotbot/react-native-…
EmilioBejasa Mar 10, 2026
77434a0
Move loadStory to instance method, remove companion object hack
EmilioBejasa Mar 11, 2026
61e330f
Add loadStory event emitter to StorybookRegistry
EmilioBejasa Mar 10, 2026
8879f62
Listen for loadStory native event in StoryRenderer
EmilioBejasa Mar 10, 2026
9e4c5db
Move loadStory event emitter to BaseStoryRendererActivity
EmilioBejasa Mar 12, 2026
2f51ef5
Add loadStory instance method to StorybookRegistry
EmilioBejasa Mar 12, 2026
2a0bc85
Refactor BaseStoryScreenshotTest to use single activity
EmilioBejasa Mar 12, 2026
a9cce63
Call loadStory via activity instead of StorybookRegistry companion
EmilioBejasa Mar 12, 2026
7944491
Trigger CI
EmilioBejasa Mar 12, 2026
e8e1cc7
Fix loadStory on new arch by using ReactHost instead of ReactInstance…
EmilioBejasa Mar 13, 2026
9bd15fb
Fix StoryRenderer to handle bootstrap story and respond to loadStory …
EmilioBejasa Mar 13, 2026
94f4719
Delay notifyStoryReady until after next frame to avoid blank screenshots
EmilioBejasa Mar 13, 2026
0ec669b
Trigger CI
EmilioBejasa Mar 13, 2026
7eb21f1
Wait for UI thread idle before taking screenshot
EmilioBejasa Mar 13, 2026
fdc2d8e
Wait for OnDrawListener before screenshotting to avoid blank frames
EmilioBejasa Mar 13, 2026
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
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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? {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<BaseStoryRendererActivity>(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<String>()

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")}")
Expand All @@ -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<BaseStoryRendererActivity>(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<BaseStoryRendererActivity>) {
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<BaseStoryRendererActivity>(intent)
waitForManifestFile(manifestFile)
scenario.close()

Log.d(TAG, "Bootstrap complete")
latch.await(2000, TimeUnit.MILLISECONDS)
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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.
Expand Down
32 changes: 26 additions & 6 deletions packages/rn-storybook-auto-screenshots/src/StoryRenderer.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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<React.ReactNode>(null);
const [error, setError] = useState<string | null>(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]);

Expand All @@ -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) {
Expand All @@ -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;
}
Expand All @@ -115,7 +135,7 @@ export function StoryRenderer({ storyName = 'MyFeature/Initial' }: StoryRenderer
}

renderStory();
}, [storyName]);
}, [currentStoryName]);

if (loading) {
return (
Expand Down
Loading