diff --git a/.github/workflows/harness-android.yml b/.github/workflows/harness-android.yml index 60bec40f43..6fb80095be 100644 --- a/.github/workflows/harness-android.yml +++ b/.github/workflows/harness-android.yml @@ -8,9 +8,8 @@ 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" @@ -18,9 +17,8 @@ on: 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: @@ -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 @@ -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: diff --git a/example/__tests__/nitro.views.harness.tsx b/example/__tests__/nitro.views.harness.tsx index 120bbef9e2..4de631acfa 100644 --- a/example/__tests__/nitro.views.harness.tsx +++ b/example/__tests__/nitro.views.harness.tsx @@ -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 { + 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() @@ -321,11 +336,14 @@ describe('TestView', () => { expect(view.nativeDefaultValue).toBe(42) expect(view.getNativeDefaultValueSetterCallCount()).toBe(0) + const updatedViewRef = deferred() await renderResult.rerender( + updatedViewRef.resolve(updatedView) + )} isBlue={true} hasBeenCalled={false} colorScheme="dark" @@ -333,6 +351,7 @@ describe('TestView', () => { /> ) + await updatedViewRef.promise expect(view.isBlue).toBe(true) expect(view.getIsBlueSetterCallCount()).toBe(2) expect(view.nativeDefaultValue).toBe(42) @@ -363,11 +382,14 @@ describe('TestView', () => { expect(view.getIsBlueSetterCallCount()).toBe(1) expect(view.getNativeDefaultValueSetterCallCount()).toBe(1) + const nativeDefaultUpdatedRef = deferred() await renderResult.rerender( + nativeDefaultUpdatedRef.resolve(updatedView) + )} isBlue={false} hasBeenCalled={false} colorScheme="dark" @@ -376,6 +398,12 @@ describe('TestView', () => { /> ) + await nativeDefaultUpdatedRef.promise + + const isBlueUpdatedRef = deferred() + const isBlueUpdatedHybridRef = callback((updatedView: TestViewRef) => + isBlueUpdatedRef.resolve(updatedView) + ) expect(view.isBlue).toBe(false) expect(view.nativeDefaultValue).toBe(2) expect(view.getIsBlueSetterCallCount()).toBe(1) @@ -385,7 +413,7 @@ describe('TestView', () => { { /> ) + await isBlueUpdatedRef.promise expect(view.isBlue).toBe(true) expect(view.nativeDefaultValue).toBe(2) expect(view.getIsBlueSetterCallCount()).toBe(2) @@ -404,7 +433,7 @@ 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() @@ -528,13 +560,17 @@ describe('multiple RecyclableTestViews', () => { expectRed(initialFirstCapture.pixelCoverage) expectRed(initialSecondCapture.pixelCoverage) + const updatedFirstRef = deferred() + const updatedFirstHybridRef = callback((view: RecyclableTestViewRef) => + updatedFirstRef.resolve(view) + ) await renderResult.rerender( { ) + await updatedFirstRef.promise expect(firstView.isBlue).toBe(true) expect(secondView.isBlue).toBe(false) expect(firstView.getOnDropViewCount()).toBe(firstOnDropViewCount) @@ -579,6 +616,12 @@ describe('multiple RecyclableTestViews', () => { ) + 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( @@ -597,6 +640,7 @@ describe('multiple RecyclableTestViews', () => { expectRed(mountedSiblingCapture.pixelCoverage) const remountedFirstRef = deferred() + const remountedFirstLayout = deferred() await renderResult.rerender( { style={INITIAL_SIZE} hybridRef={callback((view) => remountedFirstRef.resolve(view))} isBlue={true} + onLayout={({ nativeEvent }) => + remountedFirstLayout.resolve(nativeEvent.layout) + } /> { ) const remountedFirstView = await remountedFirstRef.promise + await remountedFirstLayout.promise expect(remountedFirstView.equals(firstView)).toBe( SUPPORTS_NATIVE_VIEW_RECYCLING ) @@ -750,6 +798,12 @@ describe('RecyclableTestView', () => { expectBlue(initialCapture.pixelCoverage) await renderResult.rerender() + 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( @@ -794,6 +848,12 @@ describe('RecyclableTestView', () => { expectRed(secondCapture.pixelCoverage) await renderResult.rerender() + 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 diff --git a/example/rn-harness.config.mjs b/example/rn-harness.config.mjs index a731b65e81..57eb3ad042 100644 --- a/example/rn-harness.config.mjs +++ b/example/rn-harness.config.mjs @@ -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, diff --git a/example/src/getTests.ts b/example/src/getTests.ts index 06e951217b..69a68081a6 100644 --- a/example/src/getTests.ts +++ b/example/src/getTests.ts @@ -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 } @@ -1549,7 +1550,7 @@ export function getTests( ) } return true - }) + }, PARALLEL_HYBRID_OBJECT_TEST_TIMEOUT) ) .didNotThrow() .equals(true) diff --git a/example/src/testing/createTestRunner.ts b/example/src/testing/createTestRunner.ts index ba1c645c69..271bf81039 100644 --- a/example/src/testing/createTestRunner.ts +++ b/example/src/testing/createTestRunner.ts @@ -32,21 +32,24 @@ function timeoutedPromise( } export interface TestRunner { - it(action: () => Promise): Promise> - it(action: () => T): State + it(action: () => Promise, timeout?: number): Promise> + it(action: () => T, timeout?: number): State } /** * Creates a test runner with the provided assertion backend. */ export function createTestRunner(backend: AssertionBackend): TestRunner { - function it(action: () => Promise): Promise> - function it(action: () => T): State - function it(action: () => T | Promise): State | Promise> { + function it(action: () => Promise, timeout?: number): Promise> + function it(action: () => T, timeout?: number): State + function it( + action: () => T | Promise, + timeout?: number + ): State | Promise> { try { const syncResult = action() if (syncResult instanceof Promise) { - const wrapped = timeoutedPromise(syncResult) + const wrapped = timeoutedPromise(syncResult, timeout) return wrapped .then((asyncResult) => new State(asyncResult, undefined, backend)) .catch((error) => new State(undefined, error, backend)) diff --git a/packages/react-native-nitro-test/android/src/main/java/com/margelo/nitro/test/HybridRecyclableTestView.kt b/packages/react-native-nitro-test/android/src/main/java/com/margelo/nitro/test/HybridRecyclableTestView.kt index ec50063bd8..63146b8b65 100644 --- a/packages/react-native-nitro-test/android/src/main/java/com/margelo/nitro/test/HybridRecyclableTestView.kt +++ b/packages/react-native-nitro-test/android/src/main/java/com/margelo/nitro/test/HybridRecyclableTestView.kt @@ -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 @@ -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 @@ -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 @@ -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() } } diff --git a/packages/react-native-nitro-test/android/src/main/java/com/margelo/nitro/test/HybridTestView.kt b/packages/react-native-nitro-test/android/src/main/java/com/margelo/nitro/test/HybridTestView.kt index 8b6141cad9..e3f1f6e2a9 100644 --- a/packages/react-native-nitro-test/android/src/main/java/com/margelo/nitro/test/HybridTestView.kt +++ b/packages/react-native-nitro-test/android/src/main/java/com/margelo/nitro/test/HybridTestView.kt @@ -5,6 +5,7 @@ import android.view.View import androidx.annotation.Keep import com.facebook.proguard.annotations.DoNotStrip import com.facebook.react.uimanager.ThemedReactContext +import java.util.concurrent.atomic.AtomicInteger @Keep @DoNotStrip @@ -13,7 +14,7 @@ class HybridTestView( ) : HybridTestViewSpec() { // View override val view: View = View(context) - private var onDropViewCount = 0.0 + private val onDropViewCount = AtomicInteger() private var isBlueSetterCallCount = 0.0 private var nativeDefaultValueSetterCallCount = 0.0 @@ -35,7 +36,7 @@ class HybridTestView( } // Methods - override fun getOnDropViewCount(): Double = onDropViewCount + override fun getOnDropViewCount(): Double = onDropViewCount.get().toDouble() override fun getIsBlueSetterCallCount(): Double = isBlueSetterCallCount @@ -47,6 +48,6 @@ class HybridTestView( } override fun onDropView() { - onDropViewCount += 1 + onDropViewCount.incrementAndGet() } } diff --git a/packages/react-native-nitro-test/ios/HybridRecyclableTestView.swift b/packages/react-native-nitro-test/ios/HybridRecyclableTestView.swift index 1e97005419..184b12fe05 100644 --- a/packages/react-native-nitro-test/ios/HybridRecyclableTestView.swift +++ b/packages/react-native-nitro-test/ios/HybridRecyclableTestView.swift @@ -11,6 +11,7 @@ import UIKit class HybridRecyclableTestView: HybridRecyclableTestViewSpec, RecyclableView { // UIView var view: UIView = UIView() + private let lifecycleLock = NSLock() private var isRecycled = false private var invalidLifecycleOrderCount: Double = 0 private var onDropViewCount: Double = 0 @@ -37,20 +38,22 @@ class HybridRecyclableTestView: HybridRecyclableTestViewSpec, RecyclableView { } func onDropView() { - onDropViewCount += 1 print("View dropped!") + withLifecycleLock { + onDropViewCount += 1 + } } func getOnDropViewCount() throws -> Double { - return onDropViewCount + return withLifecycleLock { onDropViewCount } } func getInvalidLifecycleOrderCount() throws -> Double { - return invalidLifecycleOrderCount + return withLifecycleLock { invalidLifecycleOrderCount } } func getPrepareForRecycleCount() throws -> Double { - return prepareForRecycleCount + return withLifecycleLock { prepareForRecycleCount } } func getNativeDefaultValueSetterCallCount() throws -> Double { @@ -63,13 +66,22 @@ class HybridRecyclableTestView: HybridRecyclableTestViewSpec, RecyclableView { // Recycling conformance func prepareForRecycle() { - if onDropViewCount != prepareForRecycleCount + 1 { - invalidLifecycleOrderCount += 1 - } - prepareForRecycleCount += 1 nativeDefaultValueStorage = 42 nativeDefaultValueSetterCallCount = 0 view.backgroundColor = .yellow isRecycled = true + + withLifecycleLock { + if onDropViewCount != prepareForRecycleCount + 1 { + invalidLifecycleOrderCount += 1 + } + prepareForRecycleCount += 1 + } + } + + private func withLifecycleLock(_ operation: () throws -> Result) rethrows -> Result { + lifecycleLock.lock() + defer { lifecycleLock.unlock() } + return try operation() } } diff --git a/packages/react-native-nitro-test/ios/HybridTestView.swift b/packages/react-native-nitro-test/ios/HybridTestView.swift index 571a5dbaf6..18b4a6964d 100644 --- a/packages/react-native-nitro-test/ios/HybridTestView.swift +++ b/packages/react-native-nitro-test/ios/HybridTestView.swift @@ -11,6 +11,7 @@ import UIKit class HybridTestView: HybridTestViewSpec { // UIView var view: UIView = UIView() + private let lifecycleLock = NSLock() private var onDropViewCount: Double = 0 private var isBlueSetterCallCount: Double = 0 private var nativeDefaultValueSetterCallCount: Double = 0 @@ -33,7 +34,7 @@ class HybridTestView: HybridTestViewSpec { // Methods func getOnDropViewCount() throws -> Double { - return onDropViewCount + return withLifecycleLock { onDropViewCount } } func getIsBlueSetterCallCount() throws -> Double { @@ -50,6 +51,14 @@ class HybridTestView: HybridTestViewSpec { } func onDropView() { - onDropViewCount += 1 + withLifecycleLock { + onDropViewCount += 1 + } + } + + private func withLifecycleLock(_ operation: () throws -> Result) rethrows -> Result { + lifecycleLock.lock() + defer { lifecycleLock.unlock() } + return try operation() } }