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
32 changes: 29 additions & 3 deletions android/app/src/androidTest/java/com/testapp/ScreenshotTest.kt
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
package com.testapp

import android.Manifest
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.drawable.GradientDrawable
import android.widget.Button
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import androidx.test.core.app.ActivityScenario
Expand Down Expand Up @@ -33,12 +36,35 @@ class ScreenshotTest {
// Wait for the app to fully load (React Native takes time to start)
Thread.sleep(15000)

// Take screenshot of the activity
scenario.onActivity { activity ->
val rootView = activity.window.decorView.rootView
Screenshot.snap(rootView)
val decorView = activity.window.decorView

val insets = decorView.rootWindowInsets
val topInset = insets?.systemWindowInsetTop ?: 0
val bottomInset = insets?.systemWindowInsetBottom ?: 0

val fullBitmap = Bitmap.createBitmap(decorView.width, decorView.height, Bitmap.Config.ARGB_8888)
decorView.draw(Canvas(fullBitmap))

val cropHeight = fullBitmap.height - topInset - bottomInset
val cropped = Bitmap.createBitmap(fullBitmap, 0, topInset, fullBitmap.width, cropHeight)
fullBitmap.recycle()

val density = activity.resources.displayMetrics.density
val imageView = ImageView(activity)
imageView.setImageBitmap(cropped)
imageView.scaleType = ImageView.ScaleType.FIT_XY

ViewHelpers.setupView(imageView)
.setExactWidthDp((cropped.width / density).toInt())
.setExactHeightDp((cropped.height / density).toInt())
.layout()

Screenshot.snap(imageView)
.setName("actual_app_home")
.record()

cropped.recycle()
}

scenario.close()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,15 @@ package com.rnstorybookautoscreenshots

import android.Manifest
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.Canvas
import android.util.Log
import android.widget.ImageView
import androidx.test.core.app.ActivityScenario
import androidx.test.core.app.ApplicationProvider
import androidx.test.rule.GrantPermissionRule
import com.facebook.testing.screenshot.Screenshot
import com.facebook.testing.screenshot.ViewHelpers
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
Expand All @@ -32,8 +36,10 @@ abstract class BaseStoryScreenshotTest {

companion object {
private const val TAG = "BaseStoryScreenshotTest"
private const val DEFAULT_LOAD_TIMEOUT_MS = 5000L
private const val DEFAULT_LOAD_TIMEOUT_MS = 10000L
private const val DEFAULT_BOOTSTRAP_TIMEOUT_MS = 10000L
private const val DEFAULT_SCREENSHOT_WIDTH_DP = 360
private const val DEFAULT_SCREENSHOT_HEIGHT_DP = 640

// Not a real story — bootstrap just needs RN to load and register all stories.
// The StoryRenderer registers stories before attempting to look up the story name,
Expand Down Expand Up @@ -65,6 +71,19 @@ abstract class BaseStoryScreenshotTest {
*/
open fun getBootstrapTimeoutMs(): Long = DEFAULT_BOOTSTRAP_TIMEOUT_MS

/**
* Override to set the screenshot viewport width in dp.
* Default is 360dp.
*/
open fun getScreenshotWidthDp(): Int = DEFAULT_SCREENSHOT_WIDTH_DP

/**
* Override to set the fallback screenshot height in dp, used when the story
* does not report a content height (e.g. flex:1 full-screen stories).
* Default is 640dp.
*/
open fun getScreenshotHeightDp(): Int = DEFAULT_SCREENSHOT_HEIGHT_DP

/**
* Override to filter which stories should be screenshotted.
* Return true to include the story, false to skip it.
Expand Down Expand Up @@ -133,6 +152,8 @@ abstract class BaseStoryScreenshotTest {
val storyName = storyInfo.toStoryName()
Log.d(TAG, "Screenshotting: $storyName (id: ${storyInfo.id})")

StorybookRegistry.resetContentHeight()

val intent = Intent(
ApplicationProvider.getApplicationContext(),
getStoryRendererActivityClass()
Expand All @@ -142,23 +163,56 @@ abstract class BaseStoryScreenshotTest {

val scenario = ActivityScenario.launch<BaseStoryRendererActivity>(intent)

// Wait for React Native to load and render
Thread.sleep(getLoadTimeoutMs())
// Poll for React Native to render and report content height, up to the timeout
val deadline = System.currentTimeMillis() + getLoadTimeoutMs()
do {
Thread.sleep(100)
} while (StorybookRegistry.getContentHeightDp() < 0 && System.currentTimeMillis() < deadline)

// Read height on test thread before posting to UI thread, to avoid racing with onLayout
val capturedHeightDp = StorybookRegistry.getContentHeightDp()
Log.d(TAG, "Content height after wait: ${capturedHeightDp}dp")

scenario.onActivity { activity ->
val rootView = activity.window.decorView.rootView
val decorView = activity.window.decorView

// Use story ID as screenshot name (replace -- with _ for filesystem compatibility)
val screenshotName = storyInfo.id.replace("--", "_")
// rootWindowInsets gives the actual system bar pixel heights
val insets = decorView.rootWindowInsets
val topInset = insets?.systemWindowInsetTop ?: 0
val bottomInset = insets?.systemWindowInsetBottom ?: 0
Log.d(TAG, "rootWindowInsets: top=$topInset, bottom=$bottomInset, screen=${decorView.width}x${decorView.height}")

val density = activity.resources.displayMetrics.density

val fullBitmap = Bitmap.createBitmap(decorView.width, decorView.height, Bitmap.Config.ARGB_8888)
decorView.draw(Canvas(fullBitmap))

// Capture screenshot using screenshot-tests-for-android
// In record mode: saves baseline images
// In verify mode: compares against baselines
Screenshot.snap(rootView)
val fullContentHeight = fullBitmap.height - topInset - bottomInset
val cropHeight = if (capturedHeightDp > 0) {
(capturedHeightDp * density).toInt().coerceAtMost(fullContentHeight)
} else {
(getScreenshotHeightDp() * density).toInt().coerceAtMost(fullContentHeight)
}
val cropWidth = (getScreenshotWidthDp() * density).toInt().coerceAtMost(fullBitmap.width)
Log.d(TAG, "Cropping: contentHeightDp=$capturedHeightDp, cropWidth=${cropWidth}px, cropHeight=${cropHeight}px")
val cropped = Bitmap.createBitmap(fullBitmap, 0, topInset, cropWidth, cropHeight)
fullBitmap.recycle()
val imageView = ImageView(activity)
imageView.setImageBitmap(cropped)
imageView.scaleType = ImageView.ScaleType.FIT_XY

ViewHelpers.setupView(imageView)
.setExactWidthDp((cropped.width / density).toInt())
.setExactHeightDp((cropped.height / density).toInt())
.layout()

val screenshotName = storyInfo.id.replace("--", "_")
Screenshot.snap(imageView)
.setName(screenshotName)
.record()

Log.d(TAG, "Screenshot captured: $screenshotName")
cropped.recycle()
Log.d(TAG, "Screenshot captured: $screenshotName (${cropped.width}x${cropped.height})")
}

scenario.close()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,17 @@ class StorybookRegistry(reactContext: ReactApplicationContext) : ReactContextBas
private const val TAG = "StorybookRegistry"
const val STORIES_FILE_NAME = "storybook_stories.json"

@Volatile
private var contentHeightDp: Float = -1f

@JvmStatic
fun getContentHeightDp(): Float = contentHeightDp

@JvmStatic
fun resetContentHeight() {
contentHeightDp = -1f
}

/**
* Read stories from the manifest file.
* Used by screenshot tests to get list of all stories.
Expand Down Expand Up @@ -51,6 +62,16 @@ class StorybookRegistry(reactContext: ReactApplicationContext) : ReactContextBas

override fun getName(): String = "StorybookRegistry"

/**
* Called from JS with the rendered height of the story content in dp.
* Used by screenshot tests to crop the bitmap to the component's natural height.
*/
@ReactMethod
fun setContentHeight(height: Float) {
Companion.contentHeightDp = height
Log.d(TAG, "Content height reported from JS: ${height}dp")
}

/**
* Called from JS to register the list of available stories.
* Writes to external files directory for test access.
Expand Down
26 changes: 23 additions & 3 deletions packages/rn-storybook-auto-screenshots/src/StoryRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,13 @@ export function StoryRenderer({ storyName = 'MyFeature/Initial' }: StoryRenderer
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);

useEffect(() => {
if (!loading && error && StorybookRegistry) {
// Story errored - signal "done, no height" to unblock the screenshot test poll
StorybookRegistry.setContentHeight(0);
}
}, [loading, error]);

useEffect(() => {
async function renderStory() {
try {
Expand Down Expand Up @@ -122,7 +129,17 @@ export function StoryRenderer({ storyName = 'MyFeature/Initial' }: StoryRenderer

return (
<View style={styles.container}>
{storyContent}
<View
style={styles.storyContent}
onLayout={(e) => {
const height = e.nativeEvent.layout.height;
if (height > 0 && StorybookRegistry) {
StorybookRegistry.setContentHeight(height);
}
}}
>
{storyContent}
</View>
</View>
);
}
Expand Down Expand Up @@ -155,10 +172,13 @@ export function getAllStories(): Array<{ id: string; title: string; name: string
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
justifyContent: 'flex-start',
alignItems: 'stretch',
backgroundColor: '#FFFFFF',
},
storyContent: {
width: 360,
},
error: {
color: 'red',
textAlign: 'center',
Expand Down
Loading