Skip to content
Closed
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
19 changes: 12 additions & 7 deletions .github/workflows/harness-android.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,19 +8,17 @@ on:
workflow_dispatch:
inputs:
device_api_level:
description: "Android API level for the emulator"
description: "Override the Android API level for both jobs"
required: false
default: "36"
type: string
device_profile:
description: "Device profile"
required: false
default: "pixel_7"
type: string
avd_name:
description: "AVD name"
description: "Override the AVD name for both jobs"
required: false
default: "Pixel_7_API_36"
type: string
push:
branches:
Expand Down Expand Up @@ -70,11 +68,8 @@ on:
- "example/rn-harness.config.mjs"

env:
# Device configuration - can be overridden by workflow_dispatch inputs
DEVICE_API_LEVEL: ${{ github.event.inputs.device_api_level || '36' }}
DEVICE_ARCH: x86_64
DEVICE_PROFILE: ${{ github.event.inputs.device_profile || 'pixel_7' }}
AVD_NAME: ${{ github.event.inputs.avd_name || 'Pixel_7_API_36' }}
AVD_DISK_SIZE: 1G
AVD_HEAP_SIZE: 1G

Expand All @@ -90,8 +85,18 @@ jobs:
sanitizer:
- name: Default
gradle_property: ""
device_api_level: "36"
avd_name: Pixel_7_API_36
- name: ASan
gradle_property: "address"
# API 33 and NDK r29 contain both sides of the legacy x86_64 ASan
# seccomp fix. Later Android versions have flaky app_process startup.
device_api_level: "33"
avd_name: Pixel_7_API_33
env:
DEVICE_API_LEVEL: ${{ github.event.inputs.device_api_level || matrix.sanitizer.device_api_level }}
AVD_NAME: ${{ github.event.inputs.avd_name || matrix.sanitizer.avd_name }}
HARNESS_TEST_TIMEOUT: 150000
steps:
- uses: actions/checkout@v7
with:
Expand Down
72 changes: 66 additions & 6 deletions example/__tests__/nitro.views.harness.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,21 @@ const RENDER_TIMEOUT = 4_000
const SUPPORTS_NATIVE_VIEW_RECYCLING =
Platform.OS === 'ios' || Number(Platform.Version) >= 28

async function waitForRecyclableViewRemoval(
view: RecyclableTestViewRef,
testID: string,
expectedOnDropViewCount: number,
expectedPrepareForRecycleCount: number
): Promise<void> {
await waitUntil(
() =>
screen.queryByTestId(testID) === null &&
view.getOnDropViewCount() === expectedOnDropViewCount &&
view.getPrepareForRecycleCount() === expectedPrepareForRecycleCount,
{ timeout: RENDER_TIMEOUT }
)
}

describe('TestView', () => {
it('renders with native props, layout, pixels, methods, and callbacks', async () => {
const viewRef = deferred<TestViewRef>()
Expand Down Expand Up @@ -321,18 +336,22 @@ describe('TestView', () => {
expect(view.nativeDefaultValue).toBe(42)
expect(view.getNativeDefaultValueSetterCallCount()).toBe(0)

const updatedViewRef = deferred<TestViewRef>()
await renderResult.rerender(
<TestView
testID="test-view-native-default"
style={INITIAL_SIZE}
hybridRef={stableHybridRef}
hybridRef={callback((updatedView) =>
updatedViewRef.resolve(updatedView)
)}
isBlue={true}
hasBeenCalled={false}
colorScheme="dark"
someCallback={stableSomeCallback}
/>
)

await updatedViewRef.promise
expect(view.isBlue).toBe(true)
expect(view.getIsBlueSetterCallCount()).toBe(2)
expect(view.nativeDefaultValue).toBe(42)
Expand Down Expand Up @@ -363,11 +382,14 @@ describe('TestView', () => {
expect(view.getIsBlueSetterCallCount()).toBe(1)
expect(view.getNativeDefaultValueSetterCallCount()).toBe(1)

const nativeDefaultUpdatedRef = deferred<TestViewRef>()
await renderResult.rerender(
<TestView
testID="test-view-setter-counts"
style={INITIAL_SIZE}
hybridRef={stableHybridRef}
hybridRef={callback((updatedView) =>
nativeDefaultUpdatedRef.resolve(updatedView)
)}
isBlue={false}
hasBeenCalled={false}
colorScheme="dark"
Expand All @@ -376,6 +398,12 @@ describe('TestView', () => {
/>
)

await nativeDefaultUpdatedRef.promise

const isBlueUpdatedRef = deferred<TestViewRef>()
const isBlueUpdatedHybridRef = callback((updatedView: TestViewRef) =>
isBlueUpdatedRef.resolve(updatedView)
)
expect(view.isBlue).toBe(false)
expect(view.nativeDefaultValue).toBe(2)
expect(view.getIsBlueSetterCallCount()).toBe(1)
Expand All @@ -385,7 +413,7 @@ describe('TestView', () => {
<TestView
testID="test-view-setter-counts"
style={INITIAL_SIZE}
hybridRef={stableHybridRef}
hybridRef={isBlueUpdatedHybridRef}
isBlue={true}
hasBeenCalled={false}
colorScheme="dark"
Expand All @@ -394,6 +422,7 @@ describe('TestView', () => {
/>
)

await isBlueUpdatedRef.promise
expect(view.isBlue).toBe(true)
expect(view.nativeDefaultValue).toBe(2)
expect(view.getIsBlueSetterCallCount()).toBe(2)
Expand All @@ -404,7 +433,7 @@ describe('TestView', () => {
<TestView
testID="test-view-setter-counts"
style={RESIZED_SIZE}
hybridRef={stableHybridRef}
hybridRef={isBlueUpdatedHybridRef}
isBlue={true}
hasBeenCalled={false}
colorScheme="dark"
Expand Down Expand Up @@ -443,9 +472,12 @@ describe('TestView', () => {

renderResult.unmount()
await waitUntil(
() => screen.queryByTestId('test-view-lifecycle') === null,
() =>
screen.queryByTestId('test-view-lifecycle') === null &&
firstView.getOnDropViewCount() === initialOnDropViewCount + 1,
{ timeout: RENDER_TIMEOUT }
)
expect(screen.queryByTestId('test-view-lifecycle')).toBeNull()
expect(firstView.getOnDropViewCount()).toBe(initialOnDropViewCount + 1)

const secondRef = deferred<TestViewRef>()
Expand Down Expand Up @@ -528,13 +560,17 @@ describe('multiple RecyclableTestViews', () => {
expectRed(initialFirstCapture.pixelCoverage)
expectRed(initialSecondCapture.pixelCoverage)

const updatedFirstRef = deferred<RecyclableTestViewRef>()
const updatedFirstHybridRef = callback((view: RecyclableTestViewRef) =>
updatedFirstRef.resolve(view)
)
await renderResult.rerender(
<View style={{ flexDirection: 'row' }}>
<RecyclableTestView
key="first"
testID="isolated-recyclable-view-first"
style={INITIAL_SIZE}
hybridRef={firstHybridRef}
hybridRef={updatedFirstHybridRef}
isBlue={true}
/>
<RecyclableTestView
Expand All @@ -547,6 +583,7 @@ describe('multiple RecyclableTestViews', () => {
</View>
)

await updatedFirstRef.promise
expect(firstView.isBlue).toBe(true)
expect(secondView.isBlue).toBe(false)
expect(firstView.getOnDropViewCount()).toBe(firstOnDropViewCount)
Expand Down Expand Up @@ -579,6 +616,12 @@ describe('multiple RecyclableTestViews', () => {
</View>
)

await waitForRecyclableViewRemoval(
firstView,
'isolated-recyclable-view-first',
firstOnDropViewCount + 1,
firstPrepareForRecycleCount + (SUPPORTS_NATIVE_VIEW_RECYCLING ? 1 : 0)
)
expect(screen.queryByTestId('isolated-recyclable-view-first')).toBeNull()
expect(firstView.getOnDropViewCount()).toBe(firstOnDropViewCount + 1)
expect(firstView.getPrepareForRecycleCount()).toBe(
Expand All @@ -597,6 +640,7 @@ describe('multiple RecyclableTestViews', () => {
expectRed(mountedSiblingCapture.pixelCoverage)

const remountedFirstRef = deferred<RecyclableTestViewRef>()
const remountedFirstLayout = deferred<LayoutRectangle>()
await renderResult.rerender(
<View style={{ flexDirection: 'row' }}>
<RecyclableTestView
Expand All @@ -605,6 +649,9 @@ describe('multiple RecyclableTestViews', () => {
style={INITIAL_SIZE}
hybridRef={callback((view) => remountedFirstRef.resolve(view))}
isBlue={true}
onLayout={({ nativeEvent }) =>
remountedFirstLayout.resolve(nativeEvent.layout)
}
/>
<RecyclableTestView
key="second"
Expand All @@ -617,6 +664,7 @@ describe('multiple RecyclableTestViews', () => {
)

const remountedFirstView = await remountedFirstRef.promise
await remountedFirstLayout.promise
expect(remountedFirstView.equals(firstView)).toBe(
SUPPORTS_NATIVE_VIEW_RECYCLING
)
Expand Down Expand Up @@ -750,6 +798,12 @@ describe('RecyclableTestView', () => {
expectBlue(initialCapture.pixelCoverage)

await renderResult.rerender(<View />)
await waitForRecyclableViewRemoval(
firstView,
'recyclable-view-lifecycle',
initialOnDropViewCount + 1,
initialPrepareForRecycleCount + (SUPPORTS_NATIVE_VIEW_RECYCLING ? 1 : 0)
)
expect(screen.queryByTestId('recyclable-view-lifecycle')).toBeNull()
expect(firstView.getOnDropViewCount()).toBe(initialOnDropViewCount + 1)
expect(firstView.getPrepareForRecycleCount()).toBe(
Expand Down Expand Up @@ -794,6 +848,12 @@ describe('RecyclableTestView', () => {
expectRed(secondCapture.pixelCoverage)

await renderResult.rerender(<View />)
await waitForRecyclableViewRemoval(
secondView,
'recyclable-view-lifecycle',
SUPPORTS_NATIVE_VIEW_RECYCLING ? initialOnDropViewCount + 2 : 1,
SUPPORTS_NATIVE_VIEW_RECYCLING ? initialPrepareForRecycleCount + 2 : 0
)
expect(screen.queryByTestId('recyclable-view-lifecycle')).toBeNull()
expect(secondView.getOnDropViewCount()).toBe(
SUPPORTS_NATIVE_VIEW_RECYCLING ? initialOnDropViewCount + 2 : 1
Expand Down
5 changes: 4 additions & 1 deletion example/rn-harness.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,10 @@ const config = {
],
defaultRunner: 'android',
resetEnvironmentBetweenTestFiles: 'runtime',
testTimeout: process.env.CI === 'true' ? 30000 : 10000,
testTimeout: Number(
process.env.HARNESS_TEST_TIMEOUT ??
(process.env.CI === 'true' ? 30000 : 10000)
),
platformReadyTimeout: process.env.CI === 'true' ? 420000 : 300000,
bridgeTimeout: process.env.CI === 'true' ? 180000 : 60000,
bundleStartTimeout: process.env.CI === 'true' ? 120000 : 60000,
Expand Down
3 changes: 2 additions & 1 deletion example/src/getTests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ export interface TestRunner {
// 2) In JVM, 51_200 is the limit for `jni::global_ref`s, then the app crashes - this intentionally exhausts that
const MEMORY_LEAK_TEST_ALLOCATION_COUNT = 55_000
const EXTERNAL_MEMORY_TEST_SIZE = 1024 * 1024
const PARALLEL_HYBRID_OBJECT_TEST_TIMEOUT = 120_000

type HermesInternal = {
getInstrumentedStats?: () => { js_externalBytes: number }
Expand Down Expand Up @@ -1549,7 +1550,7 @@ export function getTests(
)
}
return true
})
}, PARALLEL_HYBRID_OBJECT_TEST_TIMEOUT)
)
.didNotThrow()
.equals(true)
Expand Down
15 changes: 9 additions & 6 deletions example/src/testing/createTestRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,21 +32,24 @@ function timeoutedPromise<T>(
}

export interface TestRunner {
it<T>(action: () => Promise<T>): Promise<State<T>>
it<T>(action: () => T): State<T>
it<T>(action: () => Promise<T>, timeout?: number): Promise<State<T>>
it<T>(action: () => T, timeout?: number): State<T>
}

/**
* Creates a test runner with the provided assertion backend.
*/
export function createTestRunner(backend: AssertionBackend): TestRunner {
function it<T>(action: () => Promise<T>): Promise<State<T>>
function it<T>(action: () => T): State<T>
function it<T>(action: () => T | Promise<T>): State<T> | Promise<State<T>> {
function it<T>(action: () => Promise<T>, timeout?: number): Promise<State<T>>
function it<T>(action: () => T, timeout?: number): State<T>
function it<T>(
action: () => T | Promise<T>,
timeout?: number
): State<T> | Promise<State<T>> {
try {
const syncResult = action()
if (syncResult instanceof Promise) {
const wrapped = timeoutedPromise<T>(syncResult)
const wrapped = timeoutedPromise<T>(syncResult, timeout)
return wrapped
.then((asyncResult) => new State<T>(asyncResult, undefined, backend))
.catch((error) => new State<T>(undefined, error, backend))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import androidx.annotation.Keep
import com.facebook.proguard.annotations.DoNotStrip
import com.facebook.react.uimanager.ThemedReactContext
import com.margelo.nitro.views.RecyclableView
import java.util.concurrent.atomic.AtomicInteger

@Keep
@DoNotStrip
Expand All @@ -17,9 +18,9 @@ class HybridRecyclableTestView(
// View
override val view: View = View(context)
private var isRecycled = false
private var invalidLifecycleOrderCount = 0.0
private var onDropViewCount = 0.0
private var prepareForRecycleCount = 0.0
private val invalidLifecycleOrderCount = AtomicInteger()
private val onDropViewCount = AtomicInteger()
private val prepareForRecycleCount = AtomicInteger()
private var nativeDefaultValueSetterCallCount = 0.0
private var nativeDefaultValueStorage: Double? = 42.0

Expand All @@ -40,15 +41,15 @@ class HybridRecyclableTestView(
}

override fun onDropView() {
onDropViewCount += 1
Log.i(TAG, "View dropped!")
onDropViewCount.incrementAndGet()
}

override fun getOnDropViewCount(): Double = onDropViewCount
override fun getOnDropViewCount(): Double = onDropViewCount.get().toDouble()

override fun getInvalidLifecycleOrderCount(): Double = invalidLifecycleOrderCount
override fun getInvalidLifecycleOrderCount(): Double = invalidLifecycleOrderCount.get().toDouble()

override fun getPrepareForRecycleCount(): Double = prepareForRecycleCount
override fun getPrepareForRecycleCount(): Double = prepareForRecycleCount.get().toDouble()

override fun getNativeDefaultValueSetterCallCount(): Double = nativeDefaultValueSetterCallCount

Expand All @@ -58,13 +59,13 @@ class HybridRecyclableTestView(

// Recycling conformance
override fun prepareForRecycle() {
if (onDropViewCount != prepareForRecycleCount + 1) {
invalidLifecycleOrderCount += 1
if (onDropViewCount.get() != prepareForRecycleCount.get() + 1) {
invalidLifecycleOrderCount.incrementAndGet()
}
prepareForRecycleCount += 1
nativeDefaultValueStorage = 42.0
nativeDefaultValueSetterCallCount = 0.0
view.setBackgroundColor(Color.YELLOW)
isRecycled = true
prepareForRecycleCount.incrementAndGet()
}
}
Loading
Loading