@react-native-harness/ui 1.0.0-alpha.24 → 1.0.0-alpha.25
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +25 -11
- package/android/src/main/java/com/harnessui/HarnessUIModule.kt +26 -16
- package/android/src/main/java/com/harnessui/UIHelperImpl.kt +136 -41
- package/android/src/main/java/com/harnessui/ViewQueryHelper.kt +36 -15
- package/android/src/main/java/com/harnessui/ViewRegistry.kt +51 -0
- package/dist/NativeHarnessUI.d.ts +12 -2
- package/dist/NativeHarnessUI.d.ts.map +1 -1
- package/dist/WebHarnessUI.d.ts +1 -0
- package/dist/WebHarnessUI.d.ts.map +1 -1
- package/dist/WebHarnessUI.js +31 -3
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/screen.d.ts +9 -7
- package/dist/screen.d.ts.map +1 -1
- package/dist/screen.js +45 -15
- package/dist/tsconfig.lib.tsbuildinfo +1 -1
- package/dist/types.d.ts +4 -11
- package/dist/types.d.ts.map +1 -1
- package/dist/userEvent.d.ts.map +1 -1
- package/dist/userEvent.js +7 -16
- package/ios/HarnessUI.mm +58 -8
- package/ios/ViewQueryHelper.h +9 -0
- package/ios/ViewQueryHelper.mm +24 -1
- package/ios/ViewRegistry.h +8 -0
- package/ios/ViewRegistry.mm +33 -0
- package/package.json +2 -2
- package/src/NativeHarnessUI.ts +13 -3
- package/src/WebHarnessUI.ts +38 -3
- package/src/index.ts +1 -1
- package/src/screen.ts +59 -23
- package/src/types.ts +5 -11
- package/src/userEvent.ts +7 -16
package/README.md
CHANGED
|
@@ -13,7 +13,7 @@ Native UI testing module for React Native Harness that provides view queries and
|
|
|
13
13
|
|
|
14
14
|
- **View Queries**: Find elements by testID or accessibility label
|
|
15
15
|
- **Touch Simulation**: Simulate user presses and text input
|
|
16
|
-
- **Screenshot Capture**: Capture screenshots of the entire screen or
|
|
16
|
+
- **Screenshot Capture**: Capture screenshots of the entire screen, specific elements, or custom regions
|
|
17
17
|
- **Debug-Only**: Automatically excluded from release builds, only available in debug builds
|
|
18
18
|
|
|
19
19
|
## Installation
|
|
@@ -45,6 +45,14 @@ describe('My Component', () => {
|
|
|
45
45
|
|
|
46
46
|
// Take screenshots for debugging
|
|
47
47
|
const screenshot = await screen.screenshot();
|
|
48
|
+
|
|
49
|
+
// Or capture a specific region
|
|
50
|
+
const regionScreenshot = await screen.screenshot({
|
|
51
|
+
x: 0,
|
|
52
|
+
y: 0,
|
|
53
|
+
width: 100,
|
|
54
|
+
height: 100,
|
|
55
|
+
});
|
|
48
56
|
});
|
|
49
57
|
});
|
|
50
58
|
```
|
|
@@ -88,11 +96,13 @@ Queries for an element by accessibility label without throwing. Returns null if
|
|
|
88
96
|
|
|
89
97
|
Queries for all elements by accessibility label without throwing. Returns an empty array if none found.
|
|
90
98
|
|
|
91
|
-
#### `screenshot(
|
|
99
|
+
#### `screenshot(target?: ElementReference | BoundingBox): Promise<ScreenshotResult | null>`
|
|
92
100
|
|
|
93
|
-
Captures a screenshot of the entire app window or a
|
|
101
|
+
Captures a screenshot of the entire app window, a specific element, or a custom region.
|
|
94
102
|
Returns a ScreenshotResult with PNG data, or null if capture fails.
|
|
95
103
|
|
|
104
|
+
> **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.
|
|
105
|
+
|
|
96
106
|
### `userEvent`
|
|
97
107
|
|
|
98
108
|
Provides methods to simulate user interactions.
|
|
@@ -110,6 +120,7 @@ Simulates a press at the specified screen coordinates.
|
|
|
110
120
|
Simulates typing text into a text input element. Focuses the element, types each character, and blurs the element.
|
|
111
121
|
|
|
112
122
|
**TypeOptions:**
|
|
123
|
+
|
|
113
124
|
- `skipPress?: boolean` - If true, pressIn and pressOut events will not be triggered
|
|
114
125
|
- `skipBlur?: boolean` - If true, endEditing and blur events will not be triggered
|
|
115
126
|
- `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
|
|
|
118
129
|
|
|
119
130
|
### `ElementReference`
|
|
120
131
|
|
|
121
|
-
|
|
132
|
+
An opaque reference to an element found on screen.
|
|
133
|
+
|
|
134
|
+
### `BoundingBox`
|
|
135
|
+
|
|
136
|
+
Represents a region on screen.
|
|
122
137
|
|
|
123
138
|
```typescript
|
|
124
|
-
|
|
139
|
+
interface BoundingBox {
|
|
125
140
|
x: number;
|
|
126
141
|
y: number;
|
|
127
142
|
width: number;
|
|
128
143
|
height: number;
|
|
129
|
-
|
|
130
|
-
};
|
|
144
|
+
}
|
|
131
145
|
```
|
|
132
146
|
|
|
133
147
|
### `ScreenshotResult`
|
|
@@ -136,9 +150,9 @@ Screenshot result containing PNG image data.
|
|
|
136
150
|
|
|
137
151
|
```typescript
|
|
138
152
|
interface ScreenshotResult {
|
|
139
|
-
data: Uint8Array;
|
|
140
|
-
width: number;
|
|
141
|
-
height: number;
|
|
153
|
+
data: Uint8Array; // PNG image data
|
|
154
|
+
width: number; // Width in logical pixels
|
|
155
|
+
height: number; // Height in logical pixels
|
|
142
156
|
}
|
|
143
157
|
```
|
|
144
158
|
|
|
@@ -161,4 +175,4 @@ Like the project? ⚛️ [Join the team](https://callstack.com/careers/?utm_camp
|
|
|
161
175
|
[prs-welcome-badge]: https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=for-the-badge
|
|
162
176
|
[prs-welcome]: ../../CONTRIBUTING.md
|
|
163
177
|
[chat-badge]: https://img.shields.io/discord/426714625279524876.svg?style=for-the-badge
|
|
164
|
-
[chat]: https://discord.gg/xgGt7KAjxv
|
|
178
|
+
[chat]: https://discord.gg/xgGt7KAjxv
|
|
@@ -12,9 +12,9 @@ import com.facebook.react.module.annotations.ReactModule
|
|
|
12
12
|
* Includes touch simulation and view querying.
|
|
13
13
|
*/
|
|
14
14
|
@ReactModule(name = HarnessUIModule.NAME)
|
|
15
|
-
class HarnessUIModule(
|
|
16
|
-
|
|
17
|
-
|
|
15
|
+
class HarnessUIModule(
|
|
16
|
+
reactContext: ReactApplicationContext,
|
|
17
|
+
) : NativeHarnessUISpec(reactContext) {
|
|
18
18
|
companion object {
|
|
19
19
|
const val NAME = "HarnessUI"
|
|
20
20
|
}
|
|
@@ -23,31 +23,41 @@ class HarnessUIModule(reactContext: ReactApplicationContext) :
|
|
|
23
23
|
|
|
24
24
|
override fun getName(): String = NAME
|
|
25
25
|
|
|
26
|
-
override fun simulatePress(
|
|
27
|
-
|
|
26
|
+
override fun simulatePress(
|
|
27
|
+
nativeId: String,
|
|
28
|
+
x: Double,
|
|
29
|
+
y: Double,
|
|
30
|
+
promise: Promise,
|
|
31
|
+
) {
|
|
32
|
+
helper.simulatePress(nativeId, x, y, promise)
|
|
28
33
|
}
|
|
29
34
|
|
|
30
|
-
override fun queryByTestId(testId: String): WritableMap? =
|
|
31
|
-
helper.queryByTestId(testId)
|
|
35
|
+
override fun queryByTestId(testId: String): WritableMap? = helper.queryByTestId(testId)
|
|
32
36
|
|
|
33
|
-
override fun queryByAccessibilityLabel(label: String): WritableMap? =
|
|
34
|
-
helper.queryByAccessibilityLabel(label)
|
|
37
|
+
override fun queryByAccessibilityLabel(label: String): WritableMap? = helper.queryByAccessibilityLabel(label)
|
|
35
38
|
|
|
36
|
-
override fun queryAllByTestId(testId: String): WritableArray =
|
|
37
|
-
helper.queryAllByTestId(testId)
|
|
39
|
+
override fun queryAllByTestId(testId: String): WritableArray = helper.queryAllByTestId(testId)
|
|
38
40
|
|
|
39
|
-
override fun queryAllByAccessibilityLabel(label: String): WritableArray =
|
|
40
|
-
helper.queryAllByAccessibilityLabel(label)
|
|
41
|
+
override fun queryAllByAccessibilityLabel(label: String): WritableArray = helper.queryAllByAccessibilityLabel(label)
|
|
41
42
|
|
|
42
|
-
override fun captureScreenshot(
|
|
43
|
+
override fun captureScreenshot(
|
|
44
|
+
bounds: ReadableMap?,
|
|
45
|
+
promise: Promise,
|
|
46
|
+
) {
|
|
43
47
|
helper.captureScreenshot(bounds, promise)
|
|
44
48
|
}
|
|
45
49
|
|
|
46
|
-
override fun typeChar(
|
|
50
|
+
override fun typeChar(
|
|
51
|
+
character: String,
|
|
52
|
+
promise: Promise,
|
|
53
|
+
) {
|
|
47
54
|
helper.typeChar(character, promise)
|
|
48
55
|
}
|
|
49
56
|
|
|
50
|
-
override fun blur(
|
|
57
|
+
override fun blur(
|
|
58
|
+
options: ReadableMap,
|
|
59
|
+
promise: Promise,
|
|
60
|
+
) {
|
|
51
61
|
helper.blur(options, promise)
|
|
52
62
|
}
|
|
53
63
|
}
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
package com.harnessui
|
|
2
2
|
|
|
3
|
+
import android.content.Context
|
|
3
4
|
import android.graphics.Bitmap
|
|
4
5
|
import android.graphics.Canvas
|
|
6
|
+
import android.graphics.Rect
|
|
5
7
|
import android.os.Handler
|
|
6
8
|
import android.os.Looper
|
|
7
9
|
import android.os.SystemClock
|
|
@@ -9,7 +11,6 @@ import android.util.Log
|
|
|
9
11
|
import android.view.MotionEvent
|
|
10
12
|
import android.view.inputmethod.EditorInfo
|
|
11
13
|
import android.view.inputmethod.InputMethodManager
|
|
12
|
-
import android.content.Context
|
|
13
14
|
import android.widget.EditText
|
|
14
15
|
import com.facebook.react.bridge.Arguments
|
|
15
16
|
import com.facebook.react.bridge.Promise
|
|
@@ -28,12 +29,13 @@ import java.util.concurrent.TimeUnit
|
|
|
28
29
|
* UI helper implementation for HarnessUI.
|
|
29
30
|
* Includes touch simulation and view querying capabilities.
|
|
30
31
|
*/
|
|
31
|
-
class UIHelperImpl(
|
|
32
|
-
|
|
32
|
+
class UIHelperImpl(
|
|
33
|
+
private val context: ReactApplicationContext,
|
|
34
|
+
) {
|
|
33
35
|
companion object {
|
|
34
36
|
private const val TAG = "HarnessUI"
|
|
35
|
-
private const val TAP_DURATION_MS = 50L
|
|
36
|
-
private const val EVENT_PROCESSING_DELAY_MS = 10L
|
|
37
|
+
private const val TAP_DURATION_MS = 50L // Duration between touch down and up
|
|
38
|
+
private const val EVENT_PROCESSING_DELAY_MS = 10L // Delay after touch up for React Native to process the event
|
|
37
39
|
}
|
|
38
40
|
|
|
39
41
|
private val mainHandler = Handler(Looper.getMainLooper())
|
|
@@ -42,21 +44,50 @@ class UIHelperImpl(private val context: ReactApplicationContext) {
|
|
|
42
44
|
// Touch Simulation
|
|
43
45
|
// =========================================================================
|
|
44
46
|
|
|
45
|
-
fun simulatePress(
|
|
46
|
-
|
|
47
|
+
fun simulatePress(
|
|
48
|
+
nativeId: String,
|
|
49
|
+
x: Double,
|
|
50
|
+
y: Double,
|
|
51
|
+
promise: Promise,
|
|
52
|
+
) {
|
|
53
|
+
Log.i(TAG, "simulatePress called with nativeId:$nativeId x:$x y:$y")
|
|
47
54
|
|
|
48
55
|
UiThreadUtil.runOnUiThread {
|
|
49
|
-
val activity =
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
56
|
+
val activity =
|
|
57
|
+
context.currentActivity ?: run {
|
|
58
|
+
Log.w(TAG, "No current activity")
|
|
59
|
+
promise.resolve(null)
|
|
60
|
+
return@runOnUiThread
|
|
61
|
+
}
|
|
54
62
|
val root = activity.window.decorView
|
|
63
|
+
val density = root.resources.displayMetrics.density
|
|
64
|
+
|
|
65
|
+
var targetX = x
|
|
66
|
+
var targetY = y
|
|
67
|
+
|
|
68
|
+
// If nativeId is provided, try to find the view and get its current position
|
|
69
|
+
if (nativeId.isNotEmpty()) {
|
|
70
|
+
val targetView = ViewRegistry.get(nativeId)
|
|
71
|
+
if (targetView != null) {
|
|
72
|
+
val location = IntArray(2)
|
|
73
|
+
targetView.getLocationOnScreen(location)
|
|
74
|
+
// Calculate center in DP
|
|
75
|
+
val viewX = location[0] / density
|
|
76
|
+
val viewY = location[1] / density
|
|
77
|
+
val viewW = targetView.width / density
|
|
78
|
+
val viewH = targetView.height / density
|
|
79
|
+
|
|
80
|
+
targetX = (viewX + viewW / 2.0)
|
|
81
|
+
targetY = (viewY + viewH / 2.0)
|
|
82
|
+
Log.i(TAG, "Resolved view $nativeId to coordinates ($targetX, $targetY)")
|
|
83
|
+
} else {
|
|
84
|
+
Log.w(TAG, "View with nativeId $nativeId not found or collected. Using provided coordinates.")
|
|
85
|
+
}
|
|
86
|
+
}
|
|
55
87
|
|
|
56
88
|
// Convert DP to PX
|
|
57
|
-
val
|
|
58
|
-
val
|
|
59
|
-
val pxY = (y * density).toFloat()
|
|
89
|
+
val pxX = (targetX * density).toFloat()
|
|
90
|
+
val pxY = (targetY * density).toFloat()
|
|
60
91
|
|
|
61
92
|
val downTime = SystemClock.uptimeMillis()
|
|
62
93
|
|
|
@@ -115,7 +146,10 @@ class UIHelperImpl(private val context: ReactApplicationContext) {
|
|
|
115
146
|
* Executes a query on the UI thread and returns the result.
|
|
116
147
|
* Uses CountDownLatch to synchronize with the UI thread.
|
|
117
148
|
*/
|
|
118
|
-
private fun executeQuery(
|
|
149
|
+
private fun executeQuery(
|
|
150
|
+
queryType: ViewQueryType,
|
|
151
|
+
value: String,
|
|
152
|
+
): WritableMap? {
|
|
119
153
|
var result: WritableMap? = null
|
|
120
154
|
|
|
121
155
|
// If already on UI thread, execute directly
|
|
@@ -153,16 +187,20 @@ class UIHelperImpl(private val context: ReactApplicationContext) {
|
|
|
153
187
|
* Executes a query for all matching views on the UI thread.
|
|
154
188
|
* Uses CountDownLatch to synchronize with the UI thread.
|
|
155
189
|
*/
|
|
156
|
-
private fun executeQueryAll(
|
|
190
|
+
private fun executeQueryAll(
|
|
191
|
+
queryType: ViewQueryType,
|
|
192
|
+
value: String,
|
|
193
|
+
): WritableArray {
|
|
157
194
|
var result: WritableArray = Arguments.createArray()
|
|
158
195
|
|
|
159
196
|
// If already on UI thread, execute directly
|
|
160
197
|
if (UiThreadUtil.isOnUiThread()) {
|
|
161
198
|
val activity = context.currentActivity ?: return result
|
|
162
199
|
val queryResults = ViewQueryHelper.queryAll(activity, queryType, value)
|
|
163
|
-
result =
|
|
164
|
-
|
|
165
|
-
|
|
200
|
+
result =
|
|
201
|
+
Arguments.createArray().apply {
|
|
202
|
+
queryResults.forEach { pushMap(it.toWritableMap()) }
|
|
203
|
+
}
|
|
166
204
|
} else {
|
|
167
205
|
// Execute on UI thread and wait for result
|
|
168
206
|
val latch = CountDownLatch(1)
|
|
@@ -172,9 +210,10 @@ class UIHelperImpl(private val context: ReactApplicationContext) {
|
|
|
172
210
|
val activity = context.currentActivity
|
|
173
211
|
if (activity != null) {
|
|
174
212
|
val queryResults = ViewQueryHelper.queryAll(activity, queryType, value)
|
|
175
|
-
result =
|
|
176
|
-
|
|
177
|
-
|
|
213
|
+
result =
|
|
214
|
+
Arguments.createArray().apply {
|
|
215
|
+
queryResults.forEach { pushMap(it.toWritableMap()) }
|
|
216
|
+
}
|
|
178
217
|
}
|
|
179
218
|
} finally {
|
|
180
219
|
latch.countDown()
|
|
@@ -197,20 +236,68 @@ class UIHelperImpl(private val context: ReactApplicationContext) {
|
|
|
197
236
|
// Screenshot Capture
|
|
198
237
|
// =========================================================================
|
|
199
238
|
|
|
200
|
-
fun captureScreenshot(
|
|
239
|
+
fun captureScreenshot(
|
|
240
|
+
bounds: ReadableMap?,
|
|
241
|
+
promise: Promise,
|
|
242
|
+
) {
|
|
201
243
|
Log.i(TAG, "captureScreenshot called")
|
|
202
244
|
|
|
203
245
|
UiThreadUtil.runOnUiThread {
|
|
204
|
-
val activity =
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
246
|
+
val activity =
|
|
247
|
+
context.currentActivity ?: run {
|
|
248
|
+
Log.w(TAG, "No current activity")
|
|
249
|
+
promise.resolve(null)
|
|
250
|
+
return@runOnUiThread
|
|
251
|
+
}
|
|
209
252
|
|
|
210
253
|
val root = activity.window.decorView.rootView
|
|
211
254
|
val density = root.resources.displayMetrics.density
|
|
212
255
|
|
|
213
256
|
try {
|
|
257
|
+
// Check if we have a specific view to capture via nativeId
|
|
258
|
+
val nativeId = if (bounds != null && bounds.hasKey("nativeId")) bounds.getString("nativeId") else null
|
|
259
|
+
|
|
260
|
+
if (!nativeId.isNullOrEmpty()) {
|
|
261
|
+
val targetView = ViewRegistry.get(nativeId)
|
|
262
|
+
if (targetView != null) {
|
|
263
|
+
val width = targetView.width
|
|
264
|
+
val height = targetView.height
|
|
265
|
+
|
|
266
|
+
if (width > 0 && height > 0) {
|
|
267
|
+
val location = IntArray(2)
|
|
268
|
+
targetView.getLocationOnScreen(location)
|
|
269
|
+
val viewRect = Rect(location[0], location[1], location[0] + width, location[1] + height)
|
|
270
|
+
val rootRect = Rect(0, 0, root.width, root.height)
|
|
271
|
+
|
|
272
|
+
val isFullyOnScreen = rootRect.contains(viewRect)
|
|
273
|
+
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
|
|
274
|
+
val canvas = Canvas(bitmap)
|
|
275
|
+
|
|
276
|
+
if (isFullyOnScreen) {
|
|
277
|
+
Log.i(TAG, "View $nativeId is fully on screen. Using screen-based screenshotting.")
|
|
278
|
+
// Translate canvas to capture the view from the root view hierarchy
|
|
279
|
+
canvas.translate(-location[0].toFloat(), -location[1].toFloat())
|
|
280
|
+
root.draw(canvas)
|
|
281
|
+
} else {
|
|
282
|
+
Log.i(TAG, "View $nativeId is partially/fully off screen. Using direct view render.")
|
|
283
|
+
// Draw the specific view directly
|
|
284
|
+
targetView.draw(canvas)
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
val outputStream = ByteArrayOutputStream()
|
|
288
|
+
bitmap.compress(Bitmap.CompressFormat.PNG, 100, outputStream)
|
|
289
|
+
val pngBytes = outputStream.toByteArray()
|
|
290
|
+
bitmap.recycle()
|
|
291
|
+
|
|
292
|
+
val base64String = android.util.Base64.encodeToString(pngBytes, android.util.Base64.NO_WRAP)
|
|
293
|
+
promise.resolve(base64String)
|
|
294
|
+
return@runOnUiThread
|
|
295
|
+
}
|
|
296
|
+
} else {
|
|
297
|
+
Log.w(TAG, "View with nativeId $nativeId not found for screenshot, falling back to window screenshot")
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
214
301
|
// Determine capture dimensions
|
|
215
302
|
val captureX: Int
|
|
216
303
|
val captureY: Int
|
|
@@ -268,15 +355,19 @@ class UIHelperImpl(private val context: ReactApplicationContext) {
|
|
|
268
355
|
// Text Input
|
|
269
356
|
// =========================================================================
|
|
270
357
|
|
|
271
|
-
fun typeChar(
|
|
358
|
+
fun typeChar(
|
|
359
|
+
character: String,
|
|
360
|
+
promise: Promise,
|
|
361
|
+
) {
|
|
272
362
|
Log.i(TAG, "typeChar called with: $character")
|
|
273
363
|
|
|
274
364
|
UiThreadUtil.runOnUiThread {
|
|
275
|
-
val activity =
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
365
|
+
val activity =
|
|
366
|
+
context.currentActivity ?: run {
|
|
367
|
+
Log.w(TAG, "No current activity")
|
|
368
|
+
promise.resolve(null)
|
|
369
|
+
return@runOnUiThread
|
|
370
|
+
}
|
|
280
371
|
|
|
281
372
|
val focused = activity.currentFocus
|
|
282
373
|
if (focused is EditText) {
|
|
@@ -296,16 +387,20 @@ class UIHelperImpl(private val context: ReactApplicationContext) {
|
|
|
296
387
|
}
|
|
297
388
|
}
|
|
298
389
|
|
|
299
|
-
fun blur(
|
|
390
|
+
fun blur(
|
|
391
|
+
options: ReadableMap,
|
|
392
|
+
promise: Promise,
|
|
393
|
+
) {
|
|
300
394
|
val submitEditing = options.getBoolean("submitEditing")
|
|
301
395
|
Log.i(TAG, "blur called, submitEditing: $submitEditing")
|
|
302
396
|
|
|
303
397
|
UiThreadUtil.runOnUiThread {
|
|
304
|
-
val activity =
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
398
|
+
val activity =
|
|
399
|
+
context.currentActivity ?: run {
|
|
400
|
+
Log.w(TAG, "No current activity")
|
|
401
|
+
promise.resolve(null)
|
|
402
|
+
return@runOnUiThread
|
|
403
|
+
}
|
|
309
404
|
|
|
310
405
|
val focused = activity.currentFocus
|
|
311
406
|
|
|
@@ -11,7 +11,7 @@ import com.facebook.react.bridge.WritableMap
|
|
|
11
11
|
*/
|
|
12
12
|
enum class ViewQueryType {
|
|
13
13
|
TEST_ID,
|
|
14
|
-
ACCESSIBILITY_LABEL
|
|
14
|
+
ACCESSIBILITY_LABEL,
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
/**
|
|
@@ -21,16 +21,17 @@ data class ViewQueryResult(
|
|
|
21
21
|
val x: Float,
|
|
22
22
|
val y: Float,
|
|
23
23
|
val width: Float,
|
|
24
|
-
val height: Float
|
|
24
|
+
val height: Float,
|
|
25
|
+
val nativeId: String,
|
|
25
26
|
) {
|
|
26
|
-
fun toWritableMap(): WritableMap
|
|
27
|
-
|
|
27
|
+
fun toWritableMap(): WritableMap =
|
|
28
|
+
Arguments.createMap().apply {
|
|
28
29
|
putDouble("x", x.toDouble())
|
|
29
30
|
putDouble("y", y.toDouble())
|
|
30
31
|
putDouble("width", width.toDouble())
|
|
31
32
|
putDouble("height", height.toDouble())
|
|
33
|
+
putString("nativeId", nativeId)
|
|
32
34
|
}
|
|
33
|
-
}
|
|
34
35
|
}
|
|
35
36
|
|
|
36
37
|
/**
|
|
@@ -38,7 +39,6 @@ data class ViewQueryResult(
|
|
|
38
39
|
* Provides reusable query logic for finding views by various criteria.
|
|
39
40
|
*/
|
|
40
41
|
object ViewQueryHelper {
|
|
41
|
-
|
|
42
42
|
/**
|
|
43
43
|
* Finds the first view matching the query criteria.
|
|
44
44
|
* @param activity The current activity.
|
|
@@ -46,7 +46,11 @@ object ViewQueryHelper {
|
|
|
46
46
|
* @param value The value to match against.
|
|
47
47
|
* @return ViewQueryResult if found, null otherwise.
|
|
48
48
|
*/
|
|
49
|
-
fun query(
|
|
49
|
+
fun query(
|
|
50
|
+
activity: Activity,
|
|
51
|
+
queryType: ViewQueryType,
|
|
52
|
+
value: String,
|
|
53
|
+
): ViewQueryResult? {
|
|
50
54
|
val root = activity.window.decorView
|
|
51
55
|
val density = root.resources.displayMetrics.density
|
|
52
56
|
val found = findViewInView(root, queryType, value) ?: return null
|
|
@@ -60,7 +64,11 @@ object ViewQueryHelper {
|
|
|
60
64
|
* @param value The value to match against.
|
|
61
65
|
* @return List of ViewQueryResult objects.
|
|
62
66
|
*/
|
|
63
|
-
fun queryAll(
|
|
67
|
+
fun queryAll(
|
|
68
|
+
activity: Activity,
|
|
69
|
+
queryType: ViewQueryType,
|
|
70
|
+
value: String,
|
|
71
|
+
): List<ViewQueryResult> {
|
|
64
72
|
val root = activity.window.decorView
|
|
65
73
|
val density = root.resources.displayMetrics.density
|
|
66
74
|
val views = mutableListOf<View>()
|
|
@@ -71,17 +79,24 @@ object ViewQueryHelper {
|
|
|
71
79
|
/**
|
|
72
80
|
* Checks if a view matches the given query criteria.
|
|
73
81
|
*/
|
|
74
|
-
private fun viewMatches(
|
|
75
|
-
|
|
82
|
+
private fun viewMatches(
|
|
83
|
+
view: View,
|
|
84
|
+
queryType: ViewQueryType,
|
|
85
|
+
value: String,
|
|
86
|
+
): Boolean =
|
|
87
|
+
when (queryType) {
|
|
76
88
|
ViewQueryType.TEST_ID -> view.tag == value
|
|
77
89
|
ViewQueryType.ACCESSIBILITY_LABEL -> view.contentDescription?.toString() == value
|
|
78
90
|
}
|
|
79
|
-
}
|
|
80
91
|
|
|
81
92
|
/**
|
|
82
93
|
* Recursively finds the first view matching the query criteria.
|
|
83
94
|
*/
|
|
84
|
-
private fun findViewInView(
|
|
95
|
+
private fun findViewInView(
|
|
96
|
+
view: View,
|
|
97
|
+
queryType: ViewQueryType,
|
|
98
|
+
value: String,
|
|
99
|
+
): View? {
|
|
85
100
|
if (viewMatches(view, queryType, value)) {
|
|
86
101
|
return view
|
|
87
102
|
}
|
|
@@ -104,7 +119,7 @@ object ViewQueryHelper {
|
|
|
104
119
|
view: View,
|
|
105
120
|
queryType: ViewQueryType,
|
|
106
121
|
value: String,
|
|
107
|
-
results: MutableList<View
|
|
122
|
+
results: MutableList<View>,
|
|
108
123
|
) {
|
|
109
124
|
if (viewMatches(view, queryType, value)) {
|
|
110
125
|
results.add(view)
|
|
@@ -120,7 +135,10 @@ object ViewQueryHelper {
|
|
|
120
135
|
/**
|
|
121
136
|
* Converts a View to a ViewQueryResult with screen coordinates in dp.
|
|
122
137
|
*/
|
|
123
|
-
private fun resultFromView(
|
|
138
|
+
private fun resultFromView(
|
|
139
|
+
view: View,
|
|
140
|
+
density: Float,
|
|
141
|
+
): ViewQueryResult {
|
|
124
142
|
val location = IntArray(2)
|
|
125
143
|
view.getLocationOnScreen(location)
|
|
126
144
|
|
|
@@ -130,6 +148,9 @@ object ViewQueryHelper {
|
|
|
130
148
|
val width = view.width / density
|
|
131
149
|
val height = view.height / density
|
|
132
150
|
|
|
133
|
-
|
|
151
|
+
// Register the view and get its ID
|
|
152
|
+
val nativeId = ViewRegistry.register(view)
|
|
153
|
+
|
|
154
|
+
return ViewQueryResult(x, y, width, height, nativeId)
|
|
134
155
|
}
|
|
135
156
|
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
package com.harnessui
|
|
2
|
+
|
|
3
|
+
import android.view.View
|
|
4
|
+
import java.lang.ref.WeakReference
|
|
5
|
+
import java.util.UUID
|
|
6
|
+
import java.util.concurrent.ConcurrentHashMap
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A thread-safe registry that maps unique string IDs to View instances using WeakReferences.
|
|
10
|
+
* This allows efficient O(1) lookup of Views for actions without keeping them alive (preventing leaks).
|
|
11
|
+
*/
|
|
12
|
+
object ViewRegistry {
|
|
13
|
+
private val registry = ConcurrentHashMap<String, WeakReference<View>>()
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Registers a view and returns a unique ID.
|
|
17
|
+
* If the view is already registered (by object identity), it *could* return the existing ID,
|
|
18
|
+
* but for simplicity and performance we can generate a new one or use hash-based key.
|
|
19
|
+
*
|
|
20
|
+
* Currently, we generate a new random ID for every query result to ensure freshness.
|
|
21
|
+
*/
|
|
22
|
+
fun register(view: View): String {
|
|
23
|
+
val id = UUID.randomUUID().toString()
|
|
24
|
+
registry[id] = WeakReference(view)
|
|
25
|
+
return id
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Retrieves a view by its ID.
|
|
30
|
+
* Returns null if the ID doesn't exist or the View has been garbage collected.
|
|
31
|
+
*/
|
|
32
|
+
fun get(id: String): View? {
|
|
33
|
+
val ref = registry[id] ?: return null
|
|
34
|
+
return ref.get()
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Optional: Clean up stale entries.
|
|
39
|
+
* Since we use WeakReference, the objects are collected, but the keys remain.
|
|
40
|
+
* For a test harness, this memory overhead is usually negligible, but a cleanup
|
|
41
|
+
* could be triggered periodically if needed.
|
|
42
|
+
*/
|
|
43
|
+
fun prune() {
|
|
44
|
+
val iterator = registry.entries.iterator()
|
|
45
|
+
while (iterator.hasNext()) {
|
|
46
|
+
if (iterator.next().value.get() == null) {
|
|
47
|
+
iterator.remove()
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -1,12 +1,22 @@
|
|
|
1
1
|
import { type TurboModule } from 'react-native';
|
|
2
|
-
|
|
2
|
+
/**
|
|
3
|
+
* Represents a bounding box in screen coordinates (points/dp).
|
|
4
|
+
*/
|
|
5
|
+
export interface BoundingBox {
|
|
3
6
|
x: number;
|
|
4
7
|
y: number;
|
|
5
8
|
width: number;
|
|
6
9
|
height: number;
|
|
7
10
|
}
|
|
11
|
+
/**
|
|
12
|
+
* Internal interface used for bridge communication.
|
|
13
|
+
* This needs to be exported for TurboModule codegen.
|
|
14
|
+
*/
|
|
15
|
+
export interface ViewInfo extends BoundingBox {
|
|
16
|
+
nativeId: string;
|
|
17
|
+
}
|
|
8
18
|
interface Spec extends TurboModule {
|
|
9
|
-
simulatePress(x: number, y: number): Promise<void>;
|
|
19
|
+
simulatePress(nativeId: string, x: number, y: number): Promise<void>;
|
|
10
20
|
queryByTestId(testId: string): ViewInfo | null;
|
|
11
21
|
queryAllByTestId(testId: string): ViewInfo[];
|
|
12
22
|
queryByAccessibilityLabel(label: string): ViewInfo | null;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"NativeHarnessUI.d.ts","sourceRoot":"","sources":["../src/NativeHarnessUI.ts"],"names":[],"mappings":"AAAA,OAAO,EAAuB,KAAK,WAAW,EAAE,MAAM,cAAc,CAAC;
|
|
1
|
+
{"version":3,"file":"NativeHarnessUI.d.ts","sourceRoot":"","sources":["../src/NativeHarnessUI.ts"],"names":[],"mappings":"AAAA,OAAO,EAAuB,KAAK,WAAW,EAAE,MAAM,cAAc,CAAC;AAErE;;GAEG;AACH,MAAM,WAAW,WAAW;IAC1B,CAAC,EAAE,MAAM,CAAC;IACV,CAAC,EAAE,MAAM,CAAC;IACV,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;CAChB;AAED;;;GAGG;AACH,MAAM,WAAW,QAAS,SAAQ,WAAW;IAC3C,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED,UAAU,IAAK,SAAQ,WAAW;IAChC,aAAa,CAAC,QAAQ,EAAE,MAAM,EAAE,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACrE,aAAa,CAAC,MAAM,EAAE,MAAM,GAAG,QAAQ,GAAG,IAAI,CAAC;IAC/C,gBAAgB,CAAC,MAAM,EAAE,MAAM,GAAG,QAAQ,EAAE,CAAC;IAC7C,yBAAyB,CAAC,KAAK,EAAE,MAAM,GAAG,QAAQ,GAAG,IAAI,CAAC;IAC1D,4BAA4B,CAAC,KAAK,EAAE,MAAM,GAAG,QAAQ,EAAE,CAAC;IACxD,iBAAiB,CAAC,MAAM,EAAE,QAAQ,GAAG,IAAI,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IACnE,QAAQ,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC3C,IAAI,CAAC,OAAO,EAAE;QAAE,aAAa,CAAC,EAAE,OAAO,CAAA;KAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CAC3D;;AAED,wBAAmE"}
|
package/dist/WebHarnessUI.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"WebHarnessUI.d.ts","sourceRoot":"","sources":["../src/WebHarnessUI.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,eAAe,EAAE,KAAK,QAAQ,EAAE,MAAM,YAAY,CAAC;AAEjE,OAAO,CAAC,MAAM,CAAC;IACb,UAAU,MAAM;QACd,iCAAiC,EAAE,CACjC,MAAM,EAAE,QAAQ,GAAG,IAAI,KACpB,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;QAC5B,6BAA6B,EAAE,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;QACvE,wBAAwB,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;QAC/D,mBAAmB,EAAE,CAAC,OAAO,EAAE;YAC7B,aAAa,CAAC,EAAE,OAAO,CAAC;SACzB,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;
|
|
1
|
+
{"version":3,"file":"WebHarnessUI.d.ts","sourceRoot":"","sources":["../src/WebHarnessUI.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,eAAe,EAAE,KAAK,QAAQ,EAAE,MAAM,YAAY,CAAC;AAEjE,OAAO,CAAC,MAAM,CAAC;IACb,UAAU,MAAM;QACd,iCAAiC,EAAE,CACjC,MAAM,EAAE,QAAQ,GAAG,IAAI,KACpB,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;QAC5B,6BAA6B,EAAE,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;QACvE,wBAAwB,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;QAC/D,mBAAmB,EAAE,CAAC,OAAO,EAAE;YAC7B,aAAa,CAAC,EAAE,OAAO,CAAC;SACzB,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;QACpB,4BAA4B,EAAE,GAAG,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;KACpD;CACF;AA2BD,QAAA,MAAM,YAAY,EAAE,eAqEnB,CAAC;AAEF,eAAe,YAAY,CAAC"}
|