Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,13 @@ 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.WindowManager
import androidx.test.platform.app.InstrumentationRegistry
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import androidx.test.rule.GrantPermissionRule
import com.facebook.react.ReactApplication
import com.facebook.react.ReactRootView
Expand All @@ -28,9 +31,8 @@ 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.
* This test mounts a single surface once, bootstraps the story manifest, then
* drives each story via loadStory() events rather than remounting per story.
*/
abstract class BaseStoryScreenshotTest {

Expand Down Expand Up @@ -78,72 +80,78 @@ 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 ReactSurface for the entire run. The first render
* (storyName="__bootstrap__") triggers registerStoriesWithNative() and
* createPreparedStoryMapping() on the JS side. Subsequent stories are loaded
* via loadStory() events fired on the main thread, each waiting on a fresh
* CountDownLatch that notifyStoryReady() releases.
*/
@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<String>()

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)
}
}

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})")

// Prepare a latch for the bootstrap render.
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) {
// Mount a single surface for the whole test. The bootstrap render will:
// 1. Call registerStoriesWithNative() → write the manifest to disk.
// 2. Call createPreparedStoryMapping() → populate _idToPrepared.
// 3. Call notifyStoryReady() (via error path — __bootstrap__ is not a real story).
renderStory(BOOTSTRAP_STORY_NAME) { view ->
waitForManifestFile(manifestFile)
StorybookRegistry.awaitStoryReady(getBootstrapTimeoutMs())
Log.d(TAG, "Bootstrap complete, surface ready")

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
val failures = mutableListOf<String>()

for (story in stories) {
try {
StorybookRegistry.prepareForNextStory()
instrumentation.runOnMainSync {
StorybookRegistry.loadStory(story.id)
}
StorybookRegistry.awaitStoryReady(getLoadTimeoutMs())

// Wait two frames so Fabric's native view mutations are fully applied
// before we snap the software-layer bitmap.
repeat(2) {
val frameLatch = CountDownLatch(1)
instrumentation.runOnMainSync {
Choreographer.getInstance().postFrameCallback { frameLatch.countDown() }
}
frameLatch.await(1000, TimeUnit.MILLISECONDS)
}

val screenshotName = story.id.replace("--", "_")
instrumentation.runOnMainSync {
Screenshot.snap(view).setName(screenshotName).record()
}
Log.d(TAG, "Screenshot captured: $screenshotName")
successCount++
} 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, ${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")
}

/**
Expand All @@ -158,10 +166,6 @@ abstract class BaseStoryScreenshotTest {
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
Expand All @@ -184,25 +188,22 @@ abstract class BaseStoryScreenshotTest {
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE,
PixelFormat.TRANSLUCENT
).apply {
// alpha=0 lets the compositor skip this window entirely while still
// satisfying Fabric's requirement that the surface be attached to a Window.
alpha = 0f
}

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 {
onRendered(view)
} finally {
instrumentation.runOnMainSync {
surface.stop()
wm.removeView(view)
}
}
} else {
// Old arch: ReactRootView + ReactInstanceManager (deprecated API).
Expand All @@ -212,21 +213,20 @@ abstract class BaseStoryScreenshotTest {
@Suppress("DEPRECATION")
val reactInstanceManager = app.reactNativeHost.reactInstanceManager

// ReactRootView.startReactApplication() checks isOnUiThread() internally.
instrumentation.runOnMainSync {
rootView.startReactApplication(reactInstanceManager, getMainComponentName(), props)
}

// 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 {
onRendered(rootView)
} finally {
instrumentation.runOnMainSync { rootView.unmountReactApplication() }
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,6 @@ 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.
Expand All @@ -25,6 +23,12 @@ class StorybookRegistry(reactContext: ReactApplicationContext) : ReactContextBas
const val STORIES_FILE_NAME = "storybook_stories.json"

@Volatile private var storyReadyLatch: CountDownLatch? = null
@Volatile private var instance: StorybookRegistry? = null

/** Emit a loadStory event to JS, switching the active story without remounting. */
fun loadStory(storyName: String) {
instance?.loadStory(storyName)
}

/**
* Call before rendering each story. Creates a fresh latch for [awaitStoryReady].
Expand Down Expand Up @@ -69,6 +73,10 @@ class StorybookRegistry(reactContext: ReactApplicationContext) : ReactContextBas
}
}

init {
instance = this
}

override fun getName(): String = "StorybookRegistry"

/**
Expand All @@ -81,9 +89,7 @@ class StorybookRegistry(reactContext: ReactApplicationContext) : ReactContextBas
}

fun loadStory(storyName: String) {
reactApplicationContext
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
?.emit("loadStory", storyName)
reactApplicationContext.emitDeviceEvent("loadStory", storyName)
}


Expand Down
26 changes: 15 additions & 11 deletions packages/rn-storybook-auto-screenshots/src/StoryRenderer.tsx
Original file line number Diff line number Diff line change
@@ -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, DeviceEventEmitter } from 'react-native';

const { StorybookRegistry } = NativeModules;

Expand Down Expand Up @@ -53,18 +52,20 @@ export function registerStoriesWithNative() {
* Renders individual Storybook stories for screenshot testing.
* Uses Storybook's actual rendering pipeline.
*
* @param storyName - Format: "ComponentName/StoryName" (e.g., "MyFeature/Initial")
* @param storyName - Storybook story ID (e.g., "myfeature--initial")
*/
export function StoryRenderer({ storyName = 'MyFeature/Initial' }: StoryRendererProps) {
export function StoryRenderer({ storyName = 'myfeature--initial' }: StoryRendererProps) {
const [activeStoryName, setActiveStoryName] = 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) => {
console.log('loadStory event received:', name);
});
// Switch stories without remounting — native calls loadStory() to trigger a new render.
useEffect(() => {
const sub = DeviceEventEmitter.addListener('loadStory', (name: string) => {
setLoading(true);
setActiveStoryName(name);
});
return () => sub.remove();
}, []);

Expand All @@ -89,7 +90,10 @@ export function StoryRenderer({ storyName = 'MyFeature/Initial' }: StoryRenderer
// doesn't need to wait for createPreparedStoryMapping().
registerStoriesWithNative();

const storyId = storyNameToId(storyName);
// activeStoryName IS the story ID (e.g. "example-button--primary").
// Native passes the ID directly so hierarchical titles like "Example/Button"
// are handled correctly without any string conversion.
const storyId = activeStoryName;

// Lazily populate _idToPrepared — createPreparedStoryMapping() is async.
if (!storybookView._idToPrepared || Object.keys(storybookView._idToPrepared).length === 0) {
Expand Down Expand Up @@ -118,7 +122,7 @@ export function StoryRenderer({ storyName = 'MyFeature/Initial' }: StoryRenderer
}

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

if (loading) {
return (
Expand Down
Loading