Skip to content
Open
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
@@ -0,0 +1,45 @@
package com.rnstorybookautoscreenshots

import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Test
import org.junit.runner.RunWith

@RunWith(AndroidJUnit4::class)
class ComponentSurfaceTest {

@Test
fun screenshotAll() {
ReactHostFixture.screenshotAll(listOf(
// Button story variants
"Button_Primary",
"Button_Secondary",
"Button_Large",
"Button_Small",

// Header story variants
"Header_LoggedIn",
"Header_LoggedOut",

// MyFeature story variants
"MyFeature_Initial",
"MyFeature_WithClicks",
"MyFeature_ManyClicks",

// SwitchFeature story variants
"SwitchFeature_Off",
"SwitchFeature_On",

// TimerFeature story variants
"TimerFeature_Initial",
"TimerFeature_Running",
"TimerFeature_Paused",
"TimerFeature_LongDuration",

// CoinFlipFeature story variants
"CoinFlipFeature_Default",
"CoinFlipFeature_Heads",
"CoinFlipFeature_Tails",
"CoinFlipFeature_ManyFlips",
))
}
}
43 changes: 1 addition & 42 deletions android/app/src/androidTest/java/com/testapp/IsolatedTest.kt
Original file line number Diff line number Diff line change
@@ -1,55 +1,14 @@
package com.rnstorybookautoscreenshots

import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.testapp.MainApplication
import junit.framework.TestCase.assertNotNull
import junit.framework.TestCase.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import com.facebook.testing.screenshot.ViewHelpers
import com.facebook.testing.screenshot.Screenshot
import org.junit.Assert.*;
import com.facebook.react.interfaces.*

@RunWith(AndroidJUnit4::class)
class IsolatedTest {
@Test
fun simpleTest() {
assertTrue(true)
}

@Test
fun constructViewTest() {
val context = InstrumentationRegistry.getInstrumentation().targetContext
val app = context.applicationContext as MainApplication
val surface = app.reactHost.createSurface(context, "SimpleTestComponent", null)
assertEquals("SimpleTestComponent", surface.moduleName)

// TODO: we aren't 100% sure if prerender() and start() are being called the way we want it to.
// We probably want to create a ReactHost directly instead of taking it from the MainApplication... probably
// Also look up bridge-less mode.
assertGoodTask(surface.prerender())


assertNotNull(surface.view)

ViewHelpers.setupView(surface.view!!)
.setExactHeightPx(1000)
.setExactWidthPx(1000)
.layout()


val ti = surface.start()
assertGoodTask(ti)

Screenshot.snap(surface.view!!)
.record()
ReactHostFixture.screenshotComponent("ConstructViewTest")
}
}

fun assertGoodTask(ti : TaskInterface<Void>) {
ti.waitForCompletion()
assertFalse(ti.isFaulted())
assertTrue(ti.isCompleted())
}
106 changes: 106 additions & 0 deletions android/app/src/androidTest/java/com/testapp/ReactHostFixture.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
package com.rnstorybookautoscreenshots

import android.app.Application
import android.graphics.Color
import android.view.ContextThemeWrapper
import android.view.View
import androidx.test.platform.app.InstrumentationRegistry
import com.facebook.react.PackageList
import com.facebook.react.bridge.JSBundleLoader
import com.facebook.react.common.annotations.UnstableReactNativeAPI
import com.facebook.react.defaults.DefaultComponentsRegistry
import com.facebook.react.defaults.DefaultReactHostDelegate
import com.facebook.react.defaults.DefaultTurboModuleManagerDelegate
import com.facebook.react.fabric.ComponentFactory
import com.facebook.react.interfaces.fabric.ReactSurface
import com.facebook.react.runtime.ReactHostImpl
import com.facebook.react.runtime.hermes.HermesInstance
import com.facebook.testing.screenshot.ViewHelpers
import com.facebook.testing.screenshot.WindowAttachment
import com.testapp.R
import com.testapp.ScreenshotHelperModule
import com.testapp.ScreenshotHelperPackage
import junit.framework.TestCase.assertNotNull
import org.junit.Assert.assertTrue
import java.lang.ref.WeakReference
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit

@OptIn(UnstableReactNativeAPI::class)
object ReactHostFixture {
val reactHost: ReactHostImpl by lazy {
val instrumentation = InstrumentationRegistry.getInstrumentation()
val context = instrumentation.targetContext
val app = context.applicationContext as Application

val bundleLoader = JSBundleLoader.createAssetLoader(
context, "assets://index.android.bundle", true)
val delegate = DefaultReactHostDelegate(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

after PR #103 is approved and merged, I want you to refactor the code from IsolatedTest, not copy-paste a completely duplicate version

jsMainModulePath = "index",
jsBundleLoader = bundleLoader,
reactPackages = PackageList(app).packages + listOf(ScreenshotHelperPackage()),
jsRuntimeFactory = HermesInstance(),
turboModuleManagerDelegateBuilder = DefaultTurboModuleManagerDelegate.Builder(),
exceptionHandler = { throw it },
)
val componentFactory = ComponentFactory()
DefaultComponentsRegistry.register(componentFactory)
val host = ReactHostImpl(context, delegate, componentFactory, false, false)
instrumentation.runOnMainSync { host.onHostResume(null) }
host
}

/**
* Starts all [componentNames] surfaces simultaneously, waits for each JS component
* to call ScreenshotHelper.takeScreenshot, then tears everything down.
*
* All surfaces are attached and started in a single runOnMainSync, so the JS thread
* queues work for all of them at once instead of waiting for each test to finish
* before the next begins.
*/
@OptIn(UnstableReactNativeAPI::class)
fun screenshotAll(componentNames: List<String>) {
val instrumentation = InstrumentationRegistry.getInstrumentation()
val latch = CountDownLatch(componentNames.size)
ScreenshotHelperModule.sharedLatch = latch

val surfaces = mutableListOf<ReactSurface>()
val detachers = mutableListOf<WindowAttachment.Detacher?>()

componentNames.forEach { name ->
val context = ContextThemeWrapper(instrumentation.targetContext, R.style.AppTheme)
val surface = reactHost.createSurface(context, name, null)
assertNotNull("No view for $name", surface.view)
ScreenshotHelperModule.pendingViews[name] = WeakReference(surface.view!!)
surfaces.add(surface)
detachers.add(null)
}

try {
instrumentation.runOnMainSync {
surfaces.forEachIndexed { i, surface ->
val view = surface.view!!
view.setLayerType(View.LAYER_TYPE_SOFTWARE, null)
view.setBackgroundColor(Color.WHITE)
detachers[i] = WindowAttachment.dispatchAttach(view)
ViewHelpers.setupView(view).setExactWidthPx(1080).setExactHeightPx(1920).layout()
surface.start()
}
}

assertTrue(
"Timed out waiting for all screenshots: $componentNames",
latch.await(60, TimeUnit.SECONDS)
)
} finally {
ScreenshotHelperModule.pendingViews.clear()
ScreenshotHelperModule.sharedLatch = null
instrumentation.runOnMainSync {
surfaces.forEach { it.stop() }
detachers.forEach { it?.detach() }
}
}
}

fun screenshotComponent(componentName: String) = screenshotAll(listOf(componentName))
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package com.testapp

import android.view.View
import androidx.test.platform.app.InstrumentationRegistry
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod
import com.facebook.testing.screenshot.Screenshot
import java.lang.ref.WeakReference
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CountDownLatch

/**
* Native module called from JS when a component has finished rendering.
*
* [pendingViews] maps component name → view to screenshot.
* [sharedLatch] is counted down once per component that calls back,
* so the test thread can await all of them concurrently.
*/
class ScreenshotHelperModule(reactContext: ReactApplicationContext)
: ReactContextBaseJavaModule(reactContext) {

companion object {
val pendingViews = ConcurrentHashMap<String, WeakReference<View>>()
var sharedLatch: CountDownLatch? = null
}

override fun getName() = "ScreenshotHelper"

@ReactMethod
fun takeScreenshot(name: String) {
val view = pendingViews[name]?.get()
if (view == null) {
sharedLatch?.countDown()
return
}
val instrumentation = InstrumentationRegistry.getInstrumentation()
instrumentation.runOnMainSync {
Screenshot.snap(view).setName(name).record()
}
sharedLatch?.countDown()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.testapp

import com.facebook.react.ReactPackage
import com.facebook.react.bridge.NativeModule
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.uimanager.ViewManager

class ScreenshotHelperPackage : ReactPackage {
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> =
listOf(ScreenshotHelperModule(reactContext))

override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> =
emptyList()
}
104 changes: 103 additions & 1 deletion index.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
* @format
*/

import { AppRegistry, View, Text } from 'react-native';
import React, { useEffect, useState } from 'react';
import { AppRegistry, NativeModules, View, Text } from 'react-native';
import { name as appName } from './app.json';

// Configure and register StoryRenderer for screenshot tests
Expand All @@ -17,6 +18,107 @@ const { view } = require('./.rnstorybook/storybook.requires');
const SimpleTestComponent = () => <View><Text>Hello</Text></View>;
AppRegistry.registerComponent('SimpleTestComponent', () => SimpleTestComponent);


// Imports for story variants
const MyFeature = require('./MyFeature').default;
const SwitchFeature = require('./SwitchFeature').default;
const TimerFeature = require('./TimerFeature').default;
const CoinFlipFeature = require('./CoinFlipFeature').default;
const { Button } = require('./.rnstorybook/stories/Button');
const { Header } = require('./.rnstorybook/stories/Header');

const noop = () => {};

/**
* Higher-order component that wraps a story component and calls
* ScreenshotHelper.takeScreenshot from the JS side once rendered.
* requestAnimationFrame defers by one frame so native layout has settled.
*/
const withAutoScreenshot = (name, InnerComponent) => {
const Wrapper = () => {
useEffect(() => {
requestAnimationFrame(() => {
NativeModules.ScreenshotHelper.takeScreenshot(name);
});
}, []);
return <InnerComponent />;
};
return Wrapper;
};

// IsolatedTest component
const ConstructViewTest = () => <View><Text>Hello</Text></View>;
AppRegistry.registerComponent('ConstructViewTest', () => withAutoScreenshot('ConstructViewTest', ConstructViewTest));

// --- Button story variants ---

const ButtonPrimary = () => <View style={{ flex: 1, alignItems: 'flex-start' }}><Button primary label="Button" onPress={noop} /></View>;
AppRegistry.registerComponent('Button_Primary', () => withAutoScreenshot('Button_Primary', ButtonPrimary));

const ButtonSecondary = () => <View style={{ flex: 1, alignItems: 'flex-start' }}><Button label="Button" onPress={noop} /></View>;
AppRegistry.registerComponent('Button_Secondary', () => withAutoScreenshot('Button_Secondary', ButtonSecondary));

const ButtonLarge = () => <View style={{ flex: 1, alignItems: 'flex-start' }}><Button size="large" label="Button" onPress={noop} /></View>;
AppRegistry.registerComponent('Button_Large', () => withAutoScreenshot('Button_Large', ButtonLarge));

const ButtonSmall = () => <View style={{ flex: 1, alignItems: 'flex-start' }}><Button size="small" label="Button" onPress={noop} /></View>;
AppRegistry.registerComponent('Button_Small', () => withAutoScreenshot('Button_Small', ButtonSmall));

// --- Header story variants ---

const HeaderLoggedIn = () => <Header user={{ name: 'Jane Doe' }} onLogin={noop} onLogout={noop} onCreateAccount={noop} />;
AppRegistry.registerComponent('Header_LoggedIn', () => withAutoScreenshot('Header_LoggedIn', HeaderLoggedIn));

const HeaderLoggedOut = () => <Header onLogin={noop} onLogout={noop} onCreateAccount={noop} />;
AppRegistry.registerComponent('Header_LoggedOut', () => withAutoScreenshot('Header_LoggedOut', HeaderLoggedOut));

// --- MyFeature story variants ---

const MyFeatureInitial = () => { const [c, s] = useState(0); return <MyFeature clickCount={c} setClickCount={s} />; };
AppRegistry.registerComponent('MyFeature_Initial', () => withAutoScreenshot('MyFeature_Initial', MyFeatureInitial));

const MyFeatureWithClicks = () => { const [c, s] = useState(5); return <MyFeature clickCount={c} setClickCount={s} />; };
AppRegistry.registerComponent('MyFeature_WithClicks', () => withAutoScreenshot('MyFeature_WithClicks', MyFeatureWithClicks));

const MyFeatureManyClicks = () => { const [c, s] = useState(42); return <MyFeature clickCount={c} setClickCount={s} />; };
AppRegistry.registerComponent('MyFeature_ManyClicks', () => withAutoScreenshot('MyFeature_ManyClicks', MyFeatureManyClicks));

// --- SwitchFeature story variants ---

const SwitchFeatureOff = () => { const [e, s] = useState(false); return <SwitchFeature isEnabled={e} setIsEnabled={s} />; };
AppRegistry.registerComponent('SwitchFeature_Off', () => withAutoScreenshot('SwitchFeature_Off', SwitchFeatureOff));

const SwitchFeatureOn = () => { const [e, s] = useState(true); return <SwitchFeature isEnabled={e} setIsEnabled={s} />; };
AppRegistry.registerComponent('SwitchFeature_On', () => withAutoScreenshot('SwitchFeature_On', SwitchFeatureOn));

// --- TimerFeature story variants ---

const TimerFeatureInitial = () => { const [s, ss] = useState(0); const [r, sr] = useState(false); return <TimerFeature seconds={s} setSeconds={ss} isRunning={r} setIsRunning={sr} />; };
AppRegistry.registerComponent('TimerFeature_Initial', () => withAutoScreenshot('TimerFeature_Initial', TimerFeatureInitial));

const TimerFeatureRunning = () => <TimerFeature seconds={19} isRunning={true} setSeconds={noop} setIsRunning={noop} />;
AppRegistry.registerComponent('TimerFeature_Running', () => withAutoScreenshot('TimerFeature_Running', TimerFeatureRunning));

const TimerFeaturePaused = () => { const [s, ss] = useState(125); const [r, sr] = useState(false); return <TimerFeature seconds={s} setSeconds={ss} isRunning={r} setIsRunning={sr} />; };
AppRegistry.registerComponent('TimerFeature_Paused', () => withAutoScreenshot('TimerFeature_Paused', TimerFeaturePaused));

const TimerFeatureLongDuration = () => { const [s, ss] = useState(3661); const [r, sr] = useState(false); return <TimerFeature seconds={s} setSeconds={ss} isRunning={r} setIsRunning={sr} />; };
AppRegistry.registerComponent('TimerFeature_LongDuration', () => withAutoScreenshot('TimerFeature_LongDuration', TimerFeatureLongDuration));

// --- CoinFlipFeature story variants ---

const CoinFlipDefault = () => { const [r, sr] = useState(''); const [f, sf] = useState(0); return <CoinFlipFeature result={r} setResult={sr} flips={f} setFlips={sf} />; };
AppRegistry.registerComponent('CoinFlipFeature_Default', () => withAutoScreenshot('CoinFlipFeature_Default', CoinFlipDefault));

const CoinFlipHeads = () => { const [r, sr] = useState('HEADS'); const [f, sf] = useState(5); return <CoinFlipFeature result={r} setResult={sr} flips={f} setFlips={sf} />; };
AppRegistry.registerComponent('CoinFlipFeature_Heads', () => withAutoScreenshot('CoinFlipFeature_Heads', CoinFlipHeads));

const CoinFlipTails = () => { const [r, sr] = useState('TAILS'); const [f, sf] = useState(10); return <CoinFlipFeature result={r} setResult={sr} flips={f} setFlips={sf} />; };
AppRegistry.registerComponent('CoinFlipFeature_Tails', () => withAutoScreenshot('CoinFlipFeature_Tails', CoinFlipTails));

const CoinFlipManyFlips = () => { const [r, sr] = useState('HEADS'); const [f, sf] = useState(99); return <CoinFlipFeature result={r} setResult={sr} flips={f} setFlips={sf} />; };
AppRegistry.registerComponent('CoinFlipFeature_ManyFlips', () => withAutoScreenshot('CoinFlipFeature_ManyFlips', CoinFlipManyFlips));

// Modes: 'app' | 'storybook'
const MODE = 'app';

Expand Down
Loading