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(