diff --git a/apps/playground/ios/Podfile.lock b/apps/playground/ios/Podfile.lock index 22b1c53e..fc9837c6 100644 --- a/apps/playground/ios/Podfile.lock +++ b/apps/playground/ios/Podfile.lock @@ -5,7 +5,7 @@ PODS: - FBLazyVector (0.82.1) - fmt (11.0.2) - glog (0.3.5) - - HarnessUI (1.0.0-alpha.20): + - HarnessUI (1.0.0-alpha.24): - boost - DoubleConversion - fast_float @@ -2592,7 +2592,7 @@ SPEC CHECKSUMS: FBLazyVector: 0aa6183b9afe3c31fc65b5d1eeef1f3c19b63bfa fmt: a40bb5bd0294ea969aaaba240a927bd33d878cdd glog: 5683914934d5b6e4240e497e0f4a3b42d1854183 - HarnessUI: 2957b94c9c4a7e6e54b636229f4aa5e3809936bf + HarnessUI: 9593f42f9c8f68200ccd07a6ed64d02de42637b1 hermes-engine: 273e30e7fb618279934b0b95ffab60ecedb7acf5 RCT-Folly: 846fda9475e61ec7bcbf8a3fe81edfcaeb090669 RCTDeprecation: f17e2ebc07876ca9ab8eb6e4b0a4e4647497ae3a diff --git a/apps/playground/rn-harness.config.mjs b/apps/playground/rn-harness.config.mjs index 9f04eba9..1bb0097c 100644 --- a/apps/playground/rn-harness.config.mjs +++ b/apps/playground/rn-harness.config.mjs @@ -69,4 +69,5 @@ export default { resetEnvironmentBetweenTestFiles: true, unstable__skipAlreadyIncludedModules: false, forwardClientLogs: true, + disableViewFlattening: true, }; diff --git a/apps/playground/src/__tests__/ui/__image_snapshots__/android/out-of-bounds.png b/apps/playground/src/__tests__/ui/__image_snapshots__/android/out-of-bounds.png new file mode 100644 index 00000000..7d7311cb Binary files /dev/null and b/apps/playground/src/__tests__/ui/__image_snapshots__/android/out-of-bounds.png differ diff --git a/apps/playground/src/__tests__/ui/__image_snapshots__/chromium/out-of-bounds.png b/apps/playground/src/__tests__/ui/__image_snapshots__/chromium/out-of-bounds.png new file mode 100644 index 00000000..f2af63da Binary files /dev/null and b/apps/playground/src/__tests__/ui/__image_snapshots__/chromium/out-of-bounds.png differ diff --git a/apps/playground/src/__tests__/ui/__image_snapshots__/ios/out-of-bounds.png b/apps/playground/src/__tests__/ui/__image_snapshots__/ios/out-of-bounds.png new file mode 100644 index 00000000..33126c31 Binary files /dev/null and b/apps/playground/src/__tests__/ui/__image_snapshots__/ios/out-of-bounds.png differ diff --git a/apps/playground/src/__tests__/ui/__image_snapshots__/web/out-of-bounds.png b/apps/playground/src/__tests__/ui/__image_snapshots__/web/out-of-bounds.png new file mode 100644 index 00000000..f2af63da Binary files /dev/null and b/apps/playground/src/__tests__/ui/__image_snapshots__/web/out-of-bounds.png differ diff --git a/apps/playground/src/__tests__/ui/out-of-bounds.harness.tsx b/apps/playground/src/__tests__/ui/out-of-bounds.harness.tsx new file mode 100644 index 00000000..d6afb83d --- /dev/null +++ b/apps/playground/src/__tests__/ui/out-of-bounds.harness.tsx @@ -0,0 +1,25 @@ +import { describe, test, render, expect } from "react-native-harness"; +import { View } from "react-native"; +import { screen } from "@react-native-harness/ui"; + +const COLORS = ['red', 'green', 'blue', 'yellow', 'purple', 'orange', 'pink', 'brown', 'gray', 'black']; + +describe('Out of bounds', () => { + test('should screenshot specific element only', async () => { + await render( + + + {Array.from({ length: 10 }).map((_, index) => ( + + ))} + + + ); + + const element = await screen.findByTestId('out-of-bounds'); + const screenshot = await screen.screenshot(element); + await expect(screenshot).toMatchImageSnapshot({ + name: 'out-of-bounds', + }); + }); +}); \ No newline at end of file diff --git a/packages/babel-preset/package.json b/packages/babel-preset/package.json index 11526fd9..27ba007a 100644 --- a/packages/babel-preset/package.json +++ b/packages/babel-preset/package.json @@ -19,7 +19,8 @@ "babel-plugin-istanbul": "^7.0.1" }, "peerDependencies": { - "@babel/core": "^7.22.0" + "@babel/core": "^7.22.0", + "@babel/plugin-transform-react-jsx": "*" }, "devDependencies": { "@babel/core": "^7.22.0", diff --git a/packages/babel-preset/src/preset.ts b/packages/babel-preset/src/preset.ts index 54df99c3..e7e3d9d2 100644 --- a/packages/babel-preset/src/preset.ts +++ b/packages/babel-preset/src/preset.ts @@ -21,6 +21,13 @@ export const rnHarnessPlugins = [ '@babel/plugin-transform-class-static-block', resolveWeakPlugin, getIstanbulPlugin(), + [ + '@babel/plugin-transform-react-jsx', + { + runtime: 'automatic', + importSource: '@react-native-harness/runtime', + }, + ], ].filter((plugin) => plugin !== null); export const rnHarnessPreset = () => { diff --git a/packages/config/src/types.ts b/packages/config/src/types.ts index b73a6eb9..1123dd04 100644 --- a/packages/config/src/types.ts +++ b/packages/config/src/types.ts @@ -47,6 +47,15 @@ export const ConfigSchema = z .min(100, 'Crash detection interval must be at least 100ms') .default(500), + disableViewFlattening: z + .boolean() + .optional() + .default(false) + .describe( + 'Disable view flattening in React Native. This will set collapsable={true} for all View components ' + + 'to ensure they are not flattened by the native layout engine.' + ), + coverage: z .object({ root: z @@ -56,7 +65,7 @@ export const ConfigSchema = z 'Root directory for coverage instrumentation in monorepo setups. ' + 'Specifies the directory from which coverage data should be collected. ' + 'Use ".." for create-react-native-library projects where tests run from example/ ' + - 'but source files are in parent directory. Passed to babel-plugin-istanbul\'s cwd option.' + "but source files are in parent directory. Passed to babel-plugin-istanbul's cwd option." ), }) .optional(), diff --git a/packages/jest/src/setup.ts b/packages/jest/src/setup.ts index c41d7844..18ee880d 100644 --- a/packages/jest/src/setup.ts +++ b/packages/jest/src/setup.ts @@ -74,6 +74,10 @@ export const setup = async (globalConfig: JestConfig.GlobalConfig) => { } } + if (harnessConfig.disableViewFlattening) { + process.env.RN_HARNESS_VIEW_FLATTENING = 'false'; + } + logTestRunHeader(selectedRunner); const harness = await getHarness( harnessConfig, diff --git a/packages/metro/src/manifest.ts b/packages/metro/src/manifest.ts index 5c321415..bff81960 100644 --- a/packages/metro/src/manifest.ts +++ b/packages/metro/src/manifest.ts @@ -5,7 +5,8 @@ import { Config as HarnessConfig } from '@react-native-harness/config'; const getManifestContent = (harnessConfig: HarnessConfig): string => { return `global.RN_HARNESS = { appRegistryComponentName: '${harnessConfig.appRegistryComponentName}', - webSocketPort: ${harnessConfig.webSocketPort} + webSocketPort: ${harnessConfig.webSocketPort}, + disableViewFlattening: ${harnessConfig.disableViewFlattening}, };`; }; diff --git a/packages/metro/src/resolvers/resolver.ts b/packages/metro/src/resolvers/resolver.ts index 49a16ea3..a46cc1f4 100644 --- a/packages/metro/src/resolvers/resolver.ts +++ b/packages/metro/src/resolvers/resolver.ts @@ -63,6 +63,33 @@ export const createJestGlobalsResolver = (): HarnessResolver => { }; }; +export const createJsxRuntimeResolver = (): HarnessResolver => { + const resolvedJsxRuntimePath = require.resolve( + '@react-native-harness/runtime/jsx-runtime' + ); + const resolvedJsxDevRuntimePath = require.resolve( + '@react-native-harness/runtime/jsx-dev-runtime' + ); + + return (_context, moduleName, _platform) => { + if (moduleName === '@react-native-harness/runtime/jsx-runtime') { + return { + type: 'sourceFile', + filePath: resolvedJsxRuntimePath, + }; + } + + if (moduleName === '@react-native-harness/runtime/jsx-dev-runtime') { + return { + type: 'sourceFile', + filePath: resolvedJsxDevRuntimePath, + }; + } + + return null; + }; +}; + export const getHarnessResolver = ( metroConfig: MetroConfig, harnessConfig: HarnessConfig @@ -71,6 +98,7 @@ export const getHarnessResolver = ( const resolvers: HarnessResolver[] = [ createHarnessEntryPointResolver(harnessConfig), createJestGlobalsResolver(), + createJsxRuntimeResolver(), createTsConfigResolver(process.cwd()), userResolver, ].filter((resolver): resolver is HarnessResolver => !!resolver); diff --git a/packages/platform-web/src/runner.ts b/packages/platform-web/src/runner.ts index ae051af8..f59dd7a1 100644 --- a/packages/platform-web/src/runner.ts +++ b/packages/platform-web/src/runner.ts @@ -30,9 +30,37 @@ const getWebRunner = async ( await page.exposeFunction( '__RN_HARNESS_CAPTURE_SCREENSHOT__', async ( - bounds: { x: number; y: number; width: number; height: number } | null + bounds: { + x: number; + y: number; + width: number; + height: number; + nativeId: string; + } | null ) => { if (!page) return null; + + if (bounds?.nativeId) { + try { + const elementHandle = await page.evaluateHandle((id) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (window as any).__RN_HARNESS_VIEW_REGISTRY__?.get(id); + }, bounds.nativeId); + + const element = elementHandle.asElement(); + if (element) { + const buffer = await element.screenshot(); + return buffer.toString('base64'); + } + } catch (e) { + // Fallback to page screenshot if element screenshot fails + console.warn( + `Failed to capture element screenshot for ${bounds.nativeId}, falling back to clip`, + e + ); + } + } + const buffer = await page.screenshot({ clip: bounds ? { diff --git a/packages/runtime/package.json b/packages/runtime/package.json index b4ee5364..f6d89ef0 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -23,6 +23,16 @@ "types": "./dist/entry-point.d.ts", "import": "./dist/entry-point.js", "default": "./dist/entry-point.js" + }, + "./jsx-runtime": { + "development": "./src/jsx/jsx-runtime.ts", + "import": "./dist/jsx/jsx-runtime.js", + "default": "./dist/jsx/jsx-runtime.js" + }, + "./jsx-dev-runtime": { + "development": "./src/jsx/jsx-dev-runtime.ts", + "import": "./dist/jsx/jsx-dev-runtime.js", + "default": "./dist/jsx/jsx-dev-runtime.js" } }, "peerDependencies": { diff --git a/packages/runtime/src/globals.ts b/packages/runtime/src/globals.ts index 783b9f15..00e1f98c 100644 --- a/packages/runtime/src/globals.ts +++ b/packages/runtime/src/globals.ts @@ -3,6 +3,7 @@ import type { ImageSnapshotOptions } from '@react-native-harness/bridge'; export type HarnessGlobal = { appRegistryComponentName: string; webSocketPort?: number; + disableViewFlattening?: boolean; }; declare global { diff --git a/packages/runtime/src/jsx/jsx-dev-runtime.ts b/packages/runtime/src/jsx/jsx-dev-runtime.ts new file mode 100644 index 00000000..7d630da8 --- /dev/null +++ b/packages/runtime/src/jsx/jsx-dev-runtime.ts @@ -0,0 +1,29 @@ +import * as ReactJSXRuntimeDev from 'react/jsx-dev-runtime'; + +export const Fragment = ReactJSXRuntimeDev.Fragment; + +export function jsxDEV( + type: any, + props: any, + key: any, + isStaticChildren: any, + source: any, + self: any +) { + if ( + type && + (type.displayName === 'View' || type.name === 'View') && + props && + props.collapsable === undefined + ) { + props = { ...props, collapsable: true }; + } + return ReactJSXRuntimeDev.jsxDEV( + type, + props, + key, + isStaticChildren, + source, + self + ); +} diff --git a/packages/runtime/src/jsx/jsx-runtime.ts b/packages/runtime/src/jsx/jsx-runtime.ts new file mode 100644 index 00000000..0c7e8125 --- /dev/null +++ b/packages/runtime/src/jsx/jsx-runtime.ts @@ -0,0 +1,38 @@ +import { View } from 'react-native'; +import * as ReactJSXRuntime from 'react/jsx-runtime'; +import { getHarnessGlobal } from '../globals.js'; + +export const Fragment = ReactJSXRuntime.Fragment; + +function wrap( + type: React.ElementType, + props: unknown, + key: React.Key | undefined, + isStatic: boolean, +): React.ReactElement { + const disableViewFlattening = getHarnessGlobal().disableViewFlattening; + + if (disableViewFlattening && type === View) { + props = { ...(props as Record), collapsable: false }; + } + + return isStatic + ? ReactJSXRuntime.jsxs(type, props, key) + : ReactJSXRuntime.jsx(type, props, key); +} + +export function jsx( + type: React.ElementType, + props: unknown, + key?: React.Key, +): React.ReactElement { + return wrap(type, props, key, false); +} + +export function jsxs( + type: React.ElementType, + props: unknown, + key?: React.Key, +): React.ReactElement { + return wrap(type, props, key, true); +} \ No newline at end of file diff --git a/packages/ui/README.md b/packages/ui/README.md index 66e3b928..dbe22896 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -13,7 +13,7 @@ Native UI testing module for React Native Harness that provides view queries and - **View Queries**: Find elements by testID or accessibility label - **Touch Simulation**: Simulate user presses and text input -- **Screenshot Capture**: Capture screenshots of the entire screen or specific elements +- **Screenshot Capture**: Capture screenshots of the entire screen, specific elements, or custom regions - **Debug-Only**: Automatically excluded from release builds, only available in debug builds ## Installation @@ -45,6 +45,14 @@ describe('My Component', () => { // Take screenshots for debugging const screenshot = await screen.screenshot(); + + // Or capture a specific region + const regionScreenshot = await screen.screenshot({ + x: 0, + y: 0, + width: 100, + height: 100, + }); }); }); ``` @@ -88,11 +96,13 @@ Queries for an element by accessibility label without throwing. Returns null if Queries for all elements by accessibility label without throwing. Returns an empty array if none found. -#### `screenshot(element?: ElementReference): Promise` +#### `screenshot(target?: ElementReference | BoundingBox): Promise` -Captures a screenshot of the entire app window or a specific element. +Captures a screenshot of the entire app window, a specific element, or a custom region. Returns a ScreenshotResult with PNG data, or null if capture fails. +> **Warning**: If you are capturing screenshots of elements that extend beyond the screen boundaries (e.g., large scroll views or absolutely positioned views that are partially off-screen), you must disable view flattening in your configuration by setting `disableViewFlattening: true` in your `rn-harness.config.mjs` file. + ### `userEvent` Provides methods to simulate user interactions. @@ -110,6 +120,7 @@ Simulates a press at the specified screen coordinates. Simulates typing text into a text input element. Focuses the element, types each character, and blurs the element. **TypeOptions:** + - `skipPress?: boolean` - If true, pressIn and pressOut events will not be triggered - `skipBlur?: boolean` - If true, endEditing and blur events will not be triggered - `submitEditing?: boolean` - If true, submitEditing event will be triggered after typing @@ -118,16 +129,19 @@ Simulates typing text into a text input element. Focuses the element, types each ### `ElementReference` -Represents an element found on screen with its position and dimensions. +An opaque reference to an element found on screen. + +### `BoundingBox` + +Represents a region on screen. ```typescript -type ElementReference = { +interface BoundingBox { x: number; y: number; width: number; height: number; - // ... additional view info -}; +} ``` ### `ScreenshotResult` @@ -136,9 +150,9 @@ Screenshot result containing PNG image data. ```typescript interface ScreenshotResult { - data: Uint8Array; // PNG image data - width: number; // Width in logical pixels - height: number; // Height in logical pixels + data: Uint8Array; // PNG image data + width: number; // Width in logical pixels + height: number; // Height in logical pixels } ``` @@ -161,4 +175,4 @@ Like the project? ⚛️ [Join the team](https://callstack.com/careers/?utm_camp [prs-welcome-badge]: https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=for-the-badge [prs-welcome]: ../../CONTRIBUTING.md [chat-badge]: https://img.shields.io/discord/426714625279524876.svg?style=for-the-badge -[chat]: https://discord.gg/xgGt7KAjxv \ No newline at end of file +[chat]: https://discord.gg/xgGt7KAjxv diff --git a/packages/ui/android/src/main/java/com/harnessui/HarnessUIModule.kt b/packages/ui/android/src/main/java/com/harnessui/HarnessUIModule.kt index ac1e8262..80a258f2 100644 --- a/packages/ui/android/src/main/java/com/harnessui/HarnessUIModule.kt +++ b/packages/ui/android/src/main/java/com/harnessui/HarnessUIModule.kt @@ -12,9 +12,9 @@ import com.facebook.react.module.annotations.ReactModule * Includes touch simulation and view querying. */ @ReactModule(name = HarnessUIModule.NAME) -class HarnessUIModule(reactContext: ReactApplicationContext) : - NativeHarnessUISpec(reactContext) { - +class HarnessUIModule( + reactContext: ReactApplicationContext, +) : NativeHarnessUISpec(reactContext) { companion object { const val NAME = "HarnessUI" } @@ -23,31 +23,41 @@ class HarnessUIModule(reactContext: ReactApplicationContext) : override fun getName(): String = NAME - override fun simulatePress(x: Double, y: Double, promise: Promise) { - helper.simulatePress(x, y, promise) + override fun simulatePress( + nativeId: String, + x: Double, + y: Double, + promise: Promise, + ) { + helper.simulatePress(nativeId, x, y, promise) } - override fun queryByTestId(testId: String): WritableMap? = - helper.queryByTestId(testId) + override fun queryByTestId(testId: String): WritableMap? = helper.queryByTestId(testId) - override fun queryByAccessibilityLabel(label: String): WritableMap? = - helper.queryByAccessibilityLabel(label) + override fun queryByAccessibilityLabel(label: String): WritableMap? = helper.queryByAccessibilityLabel(label) - override fun queryAllByTestId(testId: String): WritableArray = - helper.queryAllByTestId(testId) + override fun queryAllByTestId(testId: String): WritableArray = helper.queryAllByTestId(testId) - override fun queryAllByAccessibilityLabel(label: String): WritableArray = - helper.queryAllByAccessibilityLabel(label) + override fun queryAllByAccessibilityLabel(label: String): WritableArray = helper.queryAllByAccessibilityLabel(label) - override fun captureScreenshot(bounds: ReadableMap?, promise: Promise) { + override fun captureScreenshot( + bounds: ReadableMap?, + promise: Promise, + ) { helper.captureScreenshot(bounds, promise) } - override fun typeChar(character: String, promise: Promise) { + override fun typeChar( + character: String, + promise: Promise, + ) { helper.typeChar(character, promise) } - override fun blur(options: ReadableMap, promise: Promise) { + override fun blur( + options: ReadableMap, + promise: Promise, + ) { helper.blur(options, promise) } } diff --git a/packages/ui/android/src/main/java/com/harnessui/UIHelperImpl.kt b/packages/ui/android/src/main/java/com/harnessui/UIHelperImpl.kt index e33e27e4..44b32c7c 100644 --- a/packages/ui/android/src/main/java/com/harnessui/UIHelperImpl.kt +++ b/packages/ui/android/src/main/java/com/harnessui/UIHelperImpl.kt @@ -1,7 +1,9 @@ package com.harnessui +import android.content.Context import android.graphics.Bitmap import android.graphics.Canvas +import android.graphics.Rect import android.os.Handler import android.os.Looper import android.os.SystemClock @@ -9,7 +11,6 @@ import android.util.Log import android.view.MotionEvent import android.view.inputmethod.EditorInfo import android.view.inputmethod.InputMethodManager -import android.content.Context import android.widget.EditText import com.facebook.react.bridge.Arguments import com.facebook.react.bridge.Promise @@ -28,12 +29,13 @@ import java.util.concurrent.TimeUnit * UI helper implementation for HarnessUI. * Includes touch simulation and view querying capabilities. */ -class UIHelperImpl(private val context: ReactApplicationContext) { - +class UIHelperImpl( + private val context: ReactApplicationContext, +) { companion object { private const val TAG = "HarnessUI" - private const val TAP_DURATION_MS = 50L // Duration between touch down and up - private const val EVENT_PROCESSING_DELAY_MS = 10L // Delay after touch up for React Native to process the event + private const val TAP_DURATION_MS = 50L // Duration between touch down and up + private const val EVENT_PROCESSING_DELAY_MS = 10L // Delay after touch up for React Native to process the event } private val mainHandler = Handler(Looper.getMainLooper()) @@ -42,21 +44,50 @@ class UIHelperImpl(private val context: ReactApplicationContext) { // Touch Simulation // ========================================================================= - fun simulatePress(x: Double, y: Double, promise: Promise) { - Log.i(TAG, "simulatePress called with x:$x y:$y") + fun simulatePress( + nativeId: String, + x: Double, + y: Double, + promise: Promise, + ) { + Log.i(TAG, "simulatePress called with nativeId:$nativeId x:$x y:$y") UiThreadUtil.runOnUiThread { - val activity = context.currentActivity ?: run { - Log.w(TAG, "No current activity") - promise.resolve(null) - return@runOnUiThread - } + val activity = + context.currentActivity ?: run { + Log.w(TAG, "No current activity") + promise.resolve(null) + return@runOnUiThread + } val root = activity.window.decorView + val density = root.resources.displayMetrics.density + + var targetX = x + var targetY = y + + // If nativeId is provided, try to find the view and get its current position + if (nativeId.isNotEmpty()) { + val targetView = ViewRegistry.get(nativeId) + if (targetView != null) { + val location = IntArray(2) + targetView.getLocationOnScreen(location) + // Calculate center in DP + val viewX = location[0] / density + val viewY = location[1] / density + val viewW = targetView.width / density + val viewH = targetView.height / density + + targetX = (viewX + viewW / 2.0) + targetY = (viewY + viewH / 2.0) + Log.i(TAG, "Resolved view $nativeId to coordinates ($targetX, $targetY)") + } else { + Log.w(TAG, "View with nativeId $nativeId not found or collected. Using provided coordinates.") + } + } // Convert DP to PX - val density = root.resources.displayMetrics.density - val pxX = (x * density).toFloat() - val pxY = (y * density).toFloat() + val pxX = (targetX * density).toFloat() + val pxY = (targetY * density).toFloat() val downTime = SystemClock.uptimeMillis() @@ -115,7 +146,10 @@ class UIHelperImpl(private val context: ReactApplicationContext) { * Executes a query on the UI thread and returns the result. * Uses CountDownLatch to synchronize with the UI thread. */ - private fun executeQuery(queryType: ViewQueryType, value: String): WritableMap? { + private fun executeQuery( + queryType: ViewQueryType, + value: String, + ): WritableMap? { var result: WritableMap? = null // If already on UI thread, execute directly @@ -153,16 +187,20 @@ class UIHelperImpl(private val context: ReactApplicationContext) { * Executes a query for all matching views on the UI thread. * Uses CountDownLatch to synchronize with the UI thread. */ - private fun executeQueryAll(queryType: ViewQueryType, value: String): WritableArray { + private fun executeQueryAll( + queryType: ViewQueryType, + value: String, + ): WritableArray { var result: WritableArray = Arguments.createArray() // If already on UI thread, execute directly if (UiThreadUtil.isOnUiThread()) { val activity = context.currentActivity ?: return result val queryResults = ViewQueryHelper.queryAll(activity, queryType, value) - result = Arguments.createArray().apply { - queryResults.forEach { pushMap(it.toWritableMap()) } - } + result = + Arguments.createArray().apply { + queryResults.forEach { pushMap(it.toWritableMap()) } + } } else { // Execute on UI thread and wait for result val latch = CountDownLatch(1) @@ -172,9 +210,10 @@ class UIHelperImpl(private val context: ReactApplicationContext) { val activity = context.currentActivity if (activity != null) { val queryResults = ViewQueryHelper.queryAll(activity, queryType, value) - result = Arguments.createArray().apply { - queryResults.forEach { pushMap(it.toWritableMap()) } - } + result = + Arguments.createArray().apply { + queryResults.forEach { pushMap(it.toWritableMap()) } + } } } finally { latch.countDown() @@ -197,20 +236,68 @@ class UIHelperImpl(private val context: ReactApplicationContext) { // Screenshot Capture // ========================================================================= - fun captureScreenshot(bounds: ReadableMap?, promise: Promise) { + fun captureScreenshot( + bounds: ReadableMap?, + promise: Promise, + ) { Log.i(TAG, "captureScreenshot called") UiThreadUtil.runOnUiThread { - val activity = context.currentActivity ?: run { - Log.w(TAG, "No current activity") - promise.resolve(null) - return@runOnUiThread - } + val activity = + context.currentActivity ?: run { + Log.w(TAG, "No current activity") + promise.resolve(null) + return@runOnUiThread + } val root = activity.window.decorView.rootView val density = root.resources.displayMetrics.density try { + // Check if we have a specific view to capture via nativeId + val nativeId = if (bounds != null && bounds.hasKey("nativeId")) bounds.getString("nativeId") else null + + if (!nativeId.isNullOrEmpty()) { + val targetView = ViewRegistry.get(nativeId) + if (targetView != null) { + val width = targetView.width + val height = targetView.height + + if (width > 0 && height > 0) { + val location = IntArray(2) + targetView.getLocationOnScreen(location) + val viewRect = Rect(location[0], location[1], location[0] + width, location[1] + height) + val rootRect = Rect(0, 0, root.width, root.height) + + val isFullyOnScreen = rootRect.contains(viewRect) + val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + val canvas = Canvas(bitmap) + + if (isFullyOnScreen) { + Log.i(TAG, "View $nativeId is fully on screen. Using screen-based screenshotting.") + // Translate canvas to capture the view from the root view hierarchy + canvas.translate(-location[0].toFloat(), -location[1].toFloat()) + root.draw(canvas) + } else { + Log.i(TAG, "View $nativeId is partially/fully off screen. Using direct view render.") + // Draw the specific view directly + targetView.draw(canvas) + } + + val outputStream = ByteArrayOutputStream() + bitmap.compress(Bitmap.CompressFormat.PNG, 100, outputStream) + val pngBytes = outputStream.toByteArray() + bitmap.recycle() + + val base64String = android.util.Base64.encodeToString(pngBytes, android.util.Base64.NO_WRAP) + promise.resolve(base64String) + return@runOnUiThread + } + } else { + Log.w(TAG, "View with nativeId $nativeId not found for screenshot, falling back to window screenshot") + } + } + // Determine capture dimensions val captureX: Int val captureY: Int @@ -268,15 +355,19 @@ class UIHelperImpl(private val context: ReactApplicationContext) { // Text Input // ========================================================================= - fun typeChar(character: String, promise: Promise) { + fun typeChar( + character: String, + promise: Promise, + ) { Log.i(TAG, "typeChar called with: $character") UiThreadUtil.runOnUiThread { - val activity = context.currentActivity ?: run { - Log.w(TAG, "No current activity") - promise.resolve(null) - return@runOnUiThread - } + val activity = + context.currentActivity ?: run { + Log.w(TAG, "No current activity") + promise.resolve(null) + return@runOnUiThread + } val focused = activity.currentFocus if (focused is EditText) { @@ -296,16 +387,20 @@ class UIHelperImpl(private val context: ReactApplicationContext) { } } - fun blur(options: ReadableMap, promise: Promise) { + fun blur( + options: ReadableMap, + promise: Promise, + ) { val submitEditing = options.getBoolean("submitEditing") Log.i(TAG, "blur called, submitEditing: $submitEditing") UiThreadUtil.runOnUiThread { - val activity = context.currentActivity ?: run { - Log.w(TAG, "No current activity") - promise.resolve(null) - return@runOnUiThread - } + val activity = + context.currentActivity ?: run { + Log.w(TAG, "No current activity") + promise.resolve(null) + return@runOnUiThread + } val focused = activity.currentFocus diff --git a/packages/ui/android/src/main/java/com/harnessui/ViewQueryHelper.kt b/packages/ui/android/src/main/java/com/harnessui/ViewQueryHelper.kt index fdf77b69..cf42efe7 100644 --- a/packages/ui/android/src/main/java/com/harnessui/ViewQueryHelper.kt +++ b/packages/ui/android/src/main/java/com/harnessui/ViewQueryHelper.kt @@ -11,7 +11,7 @@ import com.facebook.react.bridge.WritableMap */ enum class ViewQueryType { TEST_ID, - ACCESSIBILITY_LABEL + ACCESSIBILITY_LABEL, } /** @@ -21,16 +21,17 @@ data class ViewQueryResult( val x: Float, val y: Float, val width: Float, - val height: Float + val height: Float, + val nativeId: String, ) { - fun toWritableMap(): WritableMap { - return Arguments.createMap().apply { + fun toWritableMap(): WritableMap = + Arguments.createMap().apply { putDouble("x", x.toDouble()) putDouble("y", y.toDouble()) putDouble("width", width.toDouble()) putDouble("height", height.toDouble()) + putString("nativeId", nativeId) } - } } /** @@ -38,7 +39,6 @@ data class ViewQueryResult( * Provides reusable query logic for finding views by various criteria. */ object ViewQueryHelper { - /** * Finds the first view matching the query criteria. * @param activity The current activity. @@ -46,7 +46,11 @@ object ViewQueryHelper { * @param value The value to match against. * @return ViewQueryResult if found, null otherwise. */ - fun query(activity: Activity, queryType: ViewQueryType, value: String): ViewQueryResult? { + fun query( + activity: Activity, + queryType: ViewQueryType, + value: String, + ): ViewQueryResult? { val root = activity.window.decorView val density = root.resources.displayMetrics.density val found = findViewInView(root, queryType, value) ?: return null @@ -60,7 +64,11 @@ object ViewQueryHelper { * @param value The value to match against. * @return List of ViewQueryResult objects. */ - fun queryAll(activity: Activity, queryType: ViewQueryType, value: String): List { + fun queryAll( + activity: Activity, + queryType: ViewQueryType, + value: String, + ): List { val root = activity.window.decorView val density = root.resources.displayMetrics.density val views = mutableListOf() @@ -71,17 +79,24 @@ object ViewQueryHelper { /** * Checks if a view matches the given query criteria. */ - private fun viewMatches(view: View, queryType: ViewQueryType, value: String): Boolean { - return when (queryType) { + private fun viewMatches( + view: View, + queryType: ViewQueryType, + value: String, + ): Boolean = + when (queryType) { ViewQueryType.TEST_ID -> view.tag == value ViewQueryType.ACCESSIBILITY_LABEL -> view.contentDescription?.toString() == value } - } /** * Recursively finds the first view matching the query criteria. */ - private fun findViewInView(view: View, queryType: ViewQueryType, value: String): View? { + private fun findViewInView( + view: View, + queryType: ViewQueryType, + value: String, + ): View? { if (viewMatches(view, queryType, value)) { return view } @@ -104,7 +119,7 @@ object ViewQueryHelper { view: View, queryType: ViewQueryType, value: String, - results: MutableList + results: MutableList, ) { if (viewMatches(view, queryType, value)) { results.add(view) @@ -120,7 +135,10 @@ object ViewQueryHelper { /** * Converts a View to a ViewQueryResult with screen coordinates in dp. */ - private fun resultFromView(view: View, density: Float): ViewQueryResult { + private fun resultFromView( + view: View, + density: Float, + ): ViewQueryResult { val location = IntArray(2) view.getLocationOnScreen(location) @@ -130,6 +148,9 @@ object ViewQueryHelper { val width = view.width / density val height = view.height / density - return ViewQueryResult(x, y, width, height) + // Register the view and get its ID + val nativeId = ViewRegistry.register(view) + + return ViewQueryResult(x, y, width, height, nativeId) } } diff --git a/packages/ui/android/src/main/java/com/harnessui/ViewRegistry.kt b/packages/ui/android/src/main/java/com/harnessui/ViewRegistry.kt new file mode 100644 index 00000000..188fd03e --- /dev/null +++ b/packages/ui/android/src/main/java/com/harnessui/ViewRegistry.kt @@ -0,0 +1,51 @@ +package com.harnessui + +import android.view.View +import java.lang.ref.WeakReference +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap + +/** + * A thread-safe registry that maps unique string IDs to View instances using WeakReferences. + * This allows efficient O(1) lookup of Views for actions without keeping them alive (preventing leaks). + */ +object ViewRegistry { + private val registry = ConcurrentHashMap>() + + /** + * Registers a view and returns a unique ID. + * If the view is already registered (by object identity), it *could* return the existing ID, + * but for simplicity and performance we can generate a new one or use hash-based key. + * + * Currently, we generate a new random ID for every query result to ensure freshness. + */ + fun register(view: View): String { + val id = UUID.randomUUID().toString() + registry[id] = WeakReference(view) + return id + } + + /** + * Retrieves a view by its ID. + * Returns null if the ID doesn't exist or the View has been garbage collected. + */ + fun get(id: String): View? { + val ref = registry[id] ?: return null + return ref.get() + } + + /** + * Optional: Clean up stale entries. + * Since we use WeakReference, the objects are collected, but the keys remain. + * For a test harness, this memory overhead is usually negligible, but a cleanup + * could be triggered periodically if needed. + */ + fun prune() { + val iterator = registry.entries.iterator() + while (iterator.hasNext()) { + if (iterator.next().value.get() == null) { + iterator.remove() + } + } + } +} diff --git a/packages/ui/ios/HarnessUI.mm b/packages/ui/ios/HarnessUI.mm index dcd118ac..f323c3b3 100644 --- a/packages/ui/ios/HarnessUI.mm +++ b/packages/ui/ios/HarnessUI.mm @@ -1,5 +1,6 @@ #import "HarnessUI.h" #import "ViewQueryHelper.h" +#import "ViewRegistry.h" #import #import #import @@ -231,17 +232,34 @@ - (void)executeTapAtPoint:(CGPoint)point completion:(void (^)(void))completion { [self executeTapAtPoint:point retryCount:0 completion:completion]; } -- (void)simulatePress:(double)x - y:(double)y - resolve:(RCTPromiseResolveBlock)resolve - reject:(RCTPromiseRejectBlock)reject { - RCTLogInfo(@"[HarnessUI] simulatePress called with x:%.2f y:%.2f", x, y); +- (void)simulatePress:(NSString *)nativeId + x:(double)x + y:(double)y + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject { + RCTLogInfo(@"[HarnessUI] simulatePress called with nativeId:%@ x:%.2f y:%.2f", nativeId, x, y); dispatch_async(dispatch_get_main_queue(), ^{ NSTimeInterval now = CACurrentMediaTime(); NSTimeInterval timeSinceLastTap = now - _lastTapTime; CGPoint point = CGPointMake(x, y); + if (nativeId && nativeId.length > 0) { + UIView *view = [ViewRegistry getView:nativeId]; + if (view) { + UIWindow *window = [self getActiveWindow]; // Or view.window? view.window is safer if attached. + if (!window) window = view.window; + + if (window) { + CGRect frameInWindow = [view convertRect:view.bounds toView:window]; + point = CGPointMake(CGRectGetMidX(frameInWindow), CGRectGetMidY(frameInWindow)); + RCTLogInfo(@"[HarnessUI] Resolved view %@ to point (%.2f, %.2f)", nativeId, point.x, point.y); + } + } else { + RCTLogWarn(@"[HarnessUI] View with nativeId %@ not found or collected. Using provided coordinates.", nativeId); + } + } + // Completion block that actually resolves the promise void (^completionBlock)(void) = ^{ // Check if tap was successful by verifying we found a window/view @@ -402,23 +420,55 @@ - (void)captureScreenshot:(JS::NativeHarnessUI::ViewInfo *)bounds RCTLogInfo(@"[HarnessUI] captureScreenshot called"); CGRect captureRect = CGRectNull; + NSString *nativeId = nil; if (bounds) { + if (bounds->nativeId().length > 0) { + nativeId = bounds->nativeId(); + } + double width = bounds->width(); double height = bounds->height(); if (width > 0 && height > 0) { captureRect = CGRectMake(bounds->x(), bounds->y(), width, height); - RCTLogInfo(@"[HarnessUI] Capturing region: x=%.2f y=%.2f w=%.2f h=%.2f", + RCTLogInfo(@"[HarnessUI] Capturing region: x=%.2f y=%.2f w=%.2f h=%.2f nativeId=%@", captureRect.origin.x, captureRect.origin.y, - captureRect.size.width, captureRect.size.height); + captureRect.size.width, captureRect.size.height, nativeId); } } else { RCTLogInfo(@"[HarnessUI] Capturing full window"); } dispatch_async(dispatch_get_main_queue(), ^{ - NSData *pngData = [ViewQueryHelper captureScreenshotWithBounds:captureRect]; + NSData *pngData = nil; + + if (nativeId) { + UIView *targetView = [ViewRegistry getView:nativeId]; + if (targetView) { + UIWindow *window = [self getActiveWindow]; + if (window) { + CGRect frameInWindow = [targetView convertRect:targetView.bounds toView:window]; + BOOL isFullyOnScreen = CGRectContainsRect(window.bounds, frameInWindow); + + if (isFullyOnScreen) { + RCTLogInfo(@"[HarnessUI] View %@ is fully on screen. Using screen-based screenshotting.", nativeId); + pngData = [ViewQueryHelper captureScreenshotWithBounds:frameInWindow]; + } else { + RCTLogInfo(@"[HarnessUI] View %@ is partially/fully off screen. Using direct view render.", nativeId); + pngData = [ViewQueryHelper captureScreenshotOfView:targetView]; + } + } else { + RCTLogWarn(@"[HarnessUI] No active window found for nativeId: %@. Using direct view render.", nativeId); + pngData = [ViewQueryHelper captureScreenshotOfView:targetView]; + } + } else { + RCTLogWarn(@"[HarnessUI] View with nativeId %@ not found. Falling back to window bounds capture.", nativeId); + pngData = [ViewQueryHelper captureScreenshotWithBounds:captureRect]; + } + } else { + pngData = [ViewQueryHelper captureScreenshotWithBounds:captureRect]; + } if (pngData) { // Return Base64 string for efficiency diff --git a/packages/ui/ios/ViewQueryHelper.h b/packages/ui/ios/ViewQueryHelper.h index 83d5fb27..4d54d86c 100644 --- a/packages/ui/ios/ViewQueryHelper.h +++ b/packages/ui/ios/ViewQueryHelper.h @@ -20,6 +20,7 @@ typedef NS_ENUM(NSInteger, ViewQueryType) { @property (nonatomic, assign) CGFloat y; @property (nonatomic, assign) CGFloat width; @property (nonatomic, assign) CGFloat height; +@property (nonatomic, copy) NSString *nativeId; - (NSDictionary *)toDictionary; @@ -59,6 +60,14 @@ typedef NS_ENUM(NSInteger, ViewQueryType) { */ + (nullable NSData *)captureScreenshotWithBounds:(CGRect)bounds; +/** + * Captures a screenshot of a specific view, rendering its layer directly. + * This allows capturing content that might be off-screen or larger than the window. + * @param view The view to capture. + * @return NSData containing PNG image data, or nil on failure. + */ ++ (nullable NSData *)captureScreenshotOfView:(UIView *)view; + @end NS_ASSUME_NONNULL_END diff --git a/packages/ui/ios/ViewQueryHelper.mm b/packages/ui/ios/ViewQueryHelper.mm index 90e911bd..54a7c38d 100644 --- a/packages/ui/ios/ViewQueryHelper.mm +++ b/packages/ui/ios/ViewQueryHelper.mm @@ -1,4 +1,5 @@ #import "ViewQueryHelper.h" +#import "ViewRegistry.h" // ============================================================================= // ViewQueryResult Implementation @@ -11,7 +12,8 @@ - (NSDictionary *)toDictionary { @"x": @(self.x), @"y": @(self.y), @"width": @(self.width), - @"height": @(self.height) + @"height": @(self.height), + @"nativeId": self.nativeId ?: @"" }; } @@ -119,6 +121,7 @@ + (ViewQueryResult *)resultFromView:(UIView *)view window:(UIWindow *)window { result.y = frameInWindow.origin.y; result.width = frameInWindow.size.width; result.height = frameInWindow.size.height; + result.nativeId = [ViewRegistry registerView:view]; return result; } @@ -184,4 +187,24 @@ + (nullable NSData *)captureScreenshotWithBounds:(CGRect)bounds { return UIImagePNGRepresentation(image); } ++ (nullable NSData *)captureScreenshotOfView:(UIView *)view { + if (!view) { + return nil; + } + + // Use UIGraphicsImageRenderer to render the view's layer + UIGraphicsImageRendererFormat *format = [[UIGraphicsImageRendererFormat alloc] init]; + format.scale = [UIScreen mainScreen].scale; + format.opaque = NO; // Allow transparency if the view has it + + UIGraphicsImageRenderer *renderer = [[UIGraphicsImageRenderer alloc] initWithSize:view.bounds.size format:format]; + + UIImage *image = [renderer imageWithActions:^(UIGraphicsImageRendererContext *context) { + // Render the view's layer directly into the context + [view.layer renderInContext:context.CGContext]; + }]; + + return UIImagePNGRepresentation(image); +} + @end diff --git a/packages/ui/ios/ViewRegistry.h b/packages/ui/ios/ViewRegistry.h new file mode 100644 index 00000000..5e36db9d --- /dev/null +++ b/packages/ui/ios/ViewRegistry.h @@ -0,0 +1,8 @@ +#import + +@interface ViewRegistry : NSObject + ++ (NSString *)registerView:(UIView *)view; ++ (UIView *)getView:(NSString *)nativeId; + +@end diff --git a/packages/ui/ios/ViewRegistry.mm b/packages/ui/ios/ViewRegistry.mm new file mode 100644 index 00000000..ed787c48 --- /dev/null +++ b/packages/ui/ios/ViewRegistry.mm @@ -0,0 +1,33 @@ +#import "ViewRegistry.h" + +@implementation ViewRegistry + ++ (NSMapTable *)registry { + static NSMapTable *registry = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + // Key: Strong (NSString), Value: Weak (UIView) + // When UIView is deallocated, it is automatically removed from the map. + registry = [NSMapTable strongToWeakObjectsMapTable]; + }); + return registry; +} + ++ (NSString *)registerView:(UIView *)view { + if (!view) return nil; + + // Generate a unique ID. Using a random UUID ensures freshness for each query. + NSString *nativeId = [[NSUUID UUID] UUIDString]; + + // Store in registry + [[self registry] setObject:view forKey:nativeId]; + + return nativeId; +} + ++ (UIView *)getView:(NSString *)nativeId { + if (!nativeId) return nil; + return [[self registry] objectForKey:nativeId]; +} + +@end diff --git a/packages/ui/src/NativeHarnessUI.ts b/packages/ui/src/NativeHarnessUI.ts index 2e733006..6c21a17e 100644 --- a/packages/ui/src/NativeHarnessUI.ts +++ b/packages/ui/src/NativeHarnessUI.ts @@ -1,15 +1,25 @@ import { TurboModuleRegistry, type TurboModule } from 'react-native'; -// This interface needs to be there for Codegen to work. -export interface ViewInfo { +/** + * Represents a bounding box in screen coordinates (points/dp). + */ +export interface BoundingBox { x: number; y: number; width: number; height: number; } +/** + * Internal interface used for bridge communication. + * This needs to be exported for TurboModule codegen. + */ +export interface ViewInfo extends BoundingBox { + nativeId: string; +} + interface Spec extends TurboModule { - simulatePress(x: number, y: number): Promise; + simulatePress(nativeId: string, x: number, y: number): Promise; queryByTestId(testId: string): ViewInfo | null; queryAllByTestId(testId: string): ViewInfo[]; queryByAccessibilityLabel(label: string): ViewInfo | null; diff --git a/packages/ui/src/WebHarnessUI.ts b/packages/ui/src/WebHarnessUI.ts index b3fd7a9d..06e4092d 100644 --- a/packages/ui/src/WebHarnessUI.ts +++ b/packages/ui/src/WebHarnessUI.ts @@ -10,22 +10,50 @@ declare global { __RN_HARNESS_BLUR__: (options: { submitEditing?: boolean; }) => Promise; + __RN_HARNESS_VIEW_REGISTRY__: Map; } } +if (!window.__RN_HARNESS_VIEW_REGISTRY__) { + window.__RN_HARNESS_VIEW_REGISTRY__ = new Map(); +} + +let nextId = 1; + const getElementViewInfo = (element: Element): ViewInfo => { const rect = element.getBoundingClientRect(); + + let nativeId = (element as any).__harnessId; + if (!nativeId) { + nativeId = `view_${nextId++}`; + (element as any).__harnessId = nativeId; + window.__RN_HARNESS_VIEW_REGISTRY__.set(nativeId, element); + } + return { x: rect.left, y: rect.top, width: rect.width, height: rect.height, + nativeId, }; }; const WebHarnessUI: HarnessUIModule = { - simulatePress: async (x, y) => { - await window.__RN_HARNESS_SIMULATE_PRESS__(x, y); + simulatePress: async (nativeId, x, y) => { + let targetX = x; + let targetY = y; + + if (nativeId) { + const element = window.__RN_HARNESS_VIEW_REGISTRY__.get(nativeId); + if (element) { + const rect = element.getBoundingClientRect(); + targetX = rect.left + rect.width / 2; + targetY = rect.top + rect.height / 2; + } + } + + await window.__RN_HARNESS_SIMULATE_PRESS__(targetX, targetY); }, queryByTestId: (testId) => { @@ -49,7 +77,14 @@ const WebHarnessUI: HarnessUIModule = { }, captureScreenshot: async (bounds) => { - return await window.__RN_HARNESS_CAPTURE_SCREENSHOT__(bounds); + let captureBounds = bounds; + if (bounds?.nativeId && bounds.width === 0 && bounds.height === 0) { + const element = window.__RN_HARNESS_VIEW_REGISTRY__.get(bounds.nativeId); + if (element) { + captureBounds = getElementViewInfo(element); + } + } + return await window.__RN_HARNESS_CAPTURE_SCREENSHOT__(captureBounds); }, typeChar: async (character) => { diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index ca127ae6..84a3f026 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -15,4 +15,4 @@ export { type ScreenshotResult, } from './screen.js'; export { userEvent, type UserEvent } from './userEvent.js'; -export type { ViewInfo } from './types.js'; +export type { ViewInfo, BoundingBox } from './types.js'; diff --git a/packages/ui/src/screen.ts b/packages/ui/src/screen.ts index 00821c0f..8f9bfdf2 100644 --- a/packages/ui/src/screen.ts +++ b/packages/ui/src/screen.ts @@ -1,12 +1,18 @@ -import { type ViewInfo } from './types.js'; +import { type ViewInfo, type BoundingBox } from './types.js'; import { waitFor } from '@react-native-harness/runtime'; import HarnessUI from './harness.js'; /** - * Represents an element found on screen with its position and dimensions. - * This can be used with userEvent.press() to interact with the element. + * Represents an element found on screen. + * This is an opaque reference that can be used with userEvent or screenshot. */ -export type ElementReference = ViewInfo; +export type ElementReference = { + readonly nativeId: string; +}; + +const wrapElement = (viewInfo: ViewInfo): ElementReference => ({ + nativeId: viewInfo.nativeId, +}); /** * Screenshot result containing PNG image data. @@ -70,11 +76,13 @@ export type Screen = { queryAllByAccessibilityLabel: (label: string) => ElementReference[]; /** - * Captures a screenshot of the entire app window or a specific element. - * @param element Optional element reference to capture. If not provided, captures the entire window. + * Captures a screenshot of the entire app window, a specific element, or a region. + * @param target Optional element reference or bounding box to capture. If not provided, captures the entire window. * @returns Promise resolving to ScreenshotResult with PNG data, or null if capture fails. */ - screenshot: (element?: ElementReference) => Promise; + screenshot: ( + target?: ElementReference | BoundingBox + ) => Promise; }; const createScreen = (): Screen => { @@ -85,7 +93,7 @@ const createScreen = (): Screen => { if (!result) { throw new Error(`Unable to find element with testID: ${testId}`); } - return result; + return wrapElement(result); }); }, @@ -95,16 +103,17 @@ const createScreen = (): Screen => { if (results.length === 0) { throw new Error(`Unable to find any elements with testID: ${testId}`); } - return results; + return results.map(wrapElement); }); }, queryByTestId: (testId: string): ElementReference | null => { - return HarnessUI.queryByTestId(testId); + const result = HarnessUI.queryByTestId(testId); + return result ? wrapElement(result) : null; }, queryAllByTestId: (testId: string): ElementReference[] => { - return HarnessUI.queryAllByTestId(testId); + return HarnessUI.queryAllByTestId(testId).map(wrapElement); }, findByAccessibilityLabel: async ( @@ -117,7 +126,7 @@ const createScreen = (): Screen => { `Unable to find element with accessibility label: ${label}` ); } - return result; + return wrapElement(result); }); }, @@ -131,31 +140,53 @@ const createScreen = (): Screen => { `Unable to find any elements with accessibility label: ${label}` ); } - return results; + return results.map(wrapElement); }); }, queryByAccessibilityLabel: (label: string): ElementReference | null => { - return HarnessUI.queryByAccessibilityLabel(label); + const result = HarnessUI.queryByAccessibilityLabel(label); + return result ? wrapElement(result) : null; }, queryAllByAccessibilityLabel: (label: string): ElementReference[] => { - return HarnessUI.queryAllByAccessibilityLabel(label); + return HarnessUI.queryAllByAccessibilityLabel(label).map(wrapElement); }, screenshot: async ( - element?: ElementReference + target?: ElementReference | BoundingBox ): Promise => { - const bounds = element ?? null; - const base64String = await HarnessUI.captureScreenshot(bounds); + let captureBounds: ViewInfo | null = null; + let targetWidth = 0; + let targetHeight = 0; + + if (target) { + if ('nativeId' in target) { + // ElementReference + captureBounds = { + nativeId: target.nativeId, + x: 0, + y: 0, + width: 0, + height: 0, + }; + } else { + // BoundingBox + captureBounds = { + nativeId: '', + ...target, + }; + targetWidth = target.width; + targetHeight = target.height; + } + } + + const base64String = await HarnessUI.captureScreenshot(captureBounds); if (!base64String) { return null; } - const width = element?.width ?? 0; - const height = element?.height ?? 0; - // Decode Base64 string to Uint8Array const binaryString = atob(base64String); const len = binaryString.length; @@ -164,10 +195,15 @@ const createScreen = (): Screen => { bytes[i] = binaryString.charCodeAt(i); } + // If we captured by nativeId, we might not know the width/height beforehand in JS. + // But the native side returns the actual captured PNG. + // Ideally we'd get the size from the native side, but currently the bridge doesn't return it. + // For now we use the provided target size or 0. + return { data: bytes, - width, - height, + width: targetWidth, + height: targetHeight, }; }, }; diff --git a/packages/ui/src/types.ts b/packages/ui/src/types.ts index db02bbe5..dad6bb24 100644 --- a/packages/ui/src/types.ts +++ b/packages/ui/src/types.ts @@ -1,19 +1,13 @@ -/** - * Represents the position and dimensions of a view in screen coordinates (points/dp). - */ -export interface ViewInfo { - x: number; - y: number; - width: number; - height: number; -} +import type { ViewInfo } from "./NativeHarnessUI.js"; + +export type { ViewInfo, BoundingBox } from "./NativeHarnessUI.js"; export interface HarnessUIModule { /** - * Simulates a native press at the specified screen coordinates. + * Simulates a native press on a view identified by nativeId, or at coordinates if nativeId is empty. * Returns a promise that resolves when the press action is complete. */ - simulatePress(x: number, y: number): Promise; + simulatePress(nativeId: string, x: number, y: number): Promise; /** * Finds a view by its testID (accessibilityIdentifier on iOS, tag on Android). diff --git a/packages/ui/src/userEvent.ts b/packages/ui/src/userEvent.ts index ed95a243..267ba3de 100644 --- a/packages/ui/src/userEvent.ts +++ b/packages/ui/src/userEvent.ts @@ -63,16 +63,15 @@ export type UserEvent = { const createUserEvent = (): UserEvent => { return { press: async (element: ElementReference): Promise => { - // Calculate center point of the element - const centerX = element.x + element.width / 2; - const centerY = element.y + element.height / 2; - await HarnessUI.simulatePress(centerX, centerY); + // We pass 0, 0 as coordinates when nativeId is provided. + // Native side will resolve the view and calculate the center. + await HarnessUI.simulatePress(element.nativeId, 0, 0); // Flush pending events to ensure onPress and other callbacks are processed await flushEvents(); }, pressAt: async (x: number, y: number): Promise => { - await HarnessUI.simulatePress(x, y); + await HarnessUI.simulatePress('', x, y); // Flush pending events to ensure onPress and other callbacks are processed await flushEvents(); }, @@ -87,20 +86,12 @@ const createUserEvent = (): UserEvent => { } ): Promise => { // Press to focus the element (triggers pressIn/pressOut unless skipPress is true) - // Note: Currently we always press to focus, the skipPress option would need - // additional implementation in simulatePress to avoid firing press events if (!options?.skipPress) { - // Calculate center point of the element - const centerX = element.x + element.width / 2; - const centerY = element.y + element.height / 2; - await HarnessUI.simulatePress(centerX, centerY); + await HarnessUI.simulatePress(element.nativeId, 0, 0); await flushEvents(); } else { - // Still need to press to focus, but ideally without press events - // For now, we press anyway - future enhancement could add a focusOnly method - const centerX = element.x + element.width / 2; - const centerY = element.y + element.height / 2; - await HarnessUI.simulatePress(centerX, centerY); + // Still need to press to focus + await HarnessUI.simulatePress(element.nativeId, 0, 0); await flushEvents(); } diff --git a/website/src/docs/getting-started/configuration.mdx b/website/src/docs/getting-started/configuration.mdx index ed55e2e4..f1ca8417 100644 --- a/website/src/docs/getting-started/configuration.mdx +++ b/website/src/docs/getting-started/configuration.mdx @@ -78,23 +78,23 @@ For Expo projects, the `entryPoint` should be set to the path specified in the ` ## All Configuration Options -| Option | Description | -| :--- | :--- | -| `entryPoint` | **Required.** Path to your React Native app's entry point file. | -| `appRegistryComponentName` | **Required.** Name of the component registered with AppRegistry. | -| `runners` | **Required.** Array of test runners (at least one required). | -| `defaultRunner` | Default runner to use when none specified. | -| `bridgeTimeout` | Bridge timeout in milliseconds (default: `60000`). | -| `bundleStartTimeout` | Bundle start timeout in milliseconds (default: `15000`). | -| `maxAppRestarts` | Maximum number of app restarts when app fails to report ready (default: `2`). | -| `resetEnvironmentBetweenTestFiles` | Reset environment between test files (default: `true`). | -| `webSocketPort` | Web socket port for bridge communication (default: `3001`). | -| `detectNativeCrashes` | Detect native app crashes during test execution (default: `true`). | -| `crashDetectionInterval` | Interval in milliseconds to check for native crashes (default: `500`). | -| `coverage` | Coverage configuration object. | -| `coverage.root` | Root directory for coverage instrumentation (default: `process.cwd()`). | -| `forwardClientLogs` | Forward console logs from the app to the terminal (default: `false`). | - +| Option | Description | +| :--------------------------------- | :---------------------------------------------------------------------------- | +| `entryPoint` | **Required.** Path to your React Native app's entry point file. | +| `appRegistryComponentName` | **Required.** Name of the component registered with AppRegistry. | +| `runners` | **Required.** Array of test runners (at least one required). | +| `defaultRunner` | Default runner to use when none specified. | +| `bridgeTimeout` | Bridge timeout in milliseconds (default: `60000`). | +| `bundleStartTimeout` | Bundle start timeout in milliseconds (default: `15000`). | +| `maxAppRestarts` | Maximum number of app restarts when app fails to report ready (default: `2`). | +| `resetEnvironmentBetweenTestFiles` | Reset environment between test files (default: `true`). | +| `webSocketPort` | Web socket port for bridge communication (default: `3001`). | +| `detectNativeCrashes` | Detect native app crashes during test execution (default: `true`). | +| `crashDetectionInterval` | Interval in milliseconds to check for native crashes (default: `500`). | +| `disableViewFlattening` | Disable view flattening in React Native (default: `false`). | +| `coverage` | Coverage configuration object. | +| `coverage.root` | Root directory for coverage instrumentation (default: `process.cwd()`). | +| `forwardClientLogs` | Forward console logs from the app to the terminal (default: `false`). | ## Test Runners @@ -276,5 +276,3 @@ Without specifying `coverageRoot`, babel-plugin-istanbul may skip instrumenting :::tip When to use coverageRoot Set `coverageRoot` when you notice 0% coverage in your reports or when source files are not being instrumented for coverage. This commonly occurs in create-react-native-library projects and other monorepo setups. ::: - - diff --git a/website/src/docs/guides/ui-testing.mdx b/website/src/docs/guides/ui-testing.mdx index 7b044e90..473f0949 100644 --- a/website/src/docs/guides/ui-testing.mdx +++ b/website/src/docs/guides/ui-testing.mdx @@ -150,6 +150,12 @@ test('screenshot with custom options', async () => { You can capture screenshots of specific elements instead of the entire screen: +:::warning +If you are capturing screenshots of elements that extend beyond the screen boundaries, you must disable view flattening in your configuration to ensure the entire element can be rendered. + +This can be done by setting `disableViewFlattening: true` in your `rn-harness.config.mjs` file. +::: + ```typescript test('capture specific element', async () => { await render(