@react-native-harness/ui 1.0.0-alpha.22
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/HarnessUI.podspec +20 -0
- package/LICENSE +20 -0
- package/README.md +164 -0
- package/android/build.gradle +77 -0
- package/android/gradle.properties +5 -0
- package/android/src/main/java/com/harnessui/HarnessUIModule.kt +53 -0
- package/android/src/main/java/com/harnessui/HarnessUIPackage.kt +33 -0
- package/android/src/main/java/com/harnessui/UIHelperImpl.kt +335 -0
- package/android/src/main/java/com/harnessui/ViewQueryHelper.kt +135 -0
- package/dist/NativeHarnessUI.d.ts +59 -0
- package/dist/NativeHarnessUI.d.ts.map +1 -0
- package/dist/NativeHarnessUI.js +2 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +11 -0
- package/dist/screen.d.ts +67 -0
- package/dist/screen.d.ts.map +1 -0
- package/dist/screen.js +76 -0
- package/dist/tsconfig.lib.tsbuildinfo +1 -0
- package/dist/userEvent.d.ts +33 -0
- package/dist/userEvent.d.ts.map +1 -0
- package/dist/userEvent.js +72 -0
- package/ios/HarnessUI.h +5 -0
- package/ios/HarnessUI.mm +526 -0
- package/ios/ViewQueryHelper.h +64 -0
- package/ios/ViewQueryHelper.mm +187 -0
- package/package.json +64 -0
- package/src/NativeHarnessUI.ts +66 -0
- package/src/index.ts +18 -0
- package/src/screen.ts +175 -0
- package/src/userEvent.ts +124 -0
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
package com.harnessui
|
|
2
|
+
|
|
3
|
+
import android.graphics.Bitmap
|
|
4
|
+
import android.graphics.Canvas
|
|
5
|
+
import android.os.Handler
|
|
6
|
+
import android.os.Looper
|
|
7
|
+
import android.os.SystemClock
|
|
8
|
+
import android.util.Log
|
|
9
|
+
import android.view.MotionEvent
|
|
10
|
+
import android.view.inputmethod.EditorInfo
|
|
11
|
+
import android.view.inputmethod.InputMethodManager
|
|
12
|
+
import android.content.Context
|
|
13
|
+
import android.widget.EditText
|
|
14
|
+
import com.facebook.react.bridge.Arguments
|
|
15
|
+
import com.facebook.react.bridge.Promise
|
|
16
|
+
import com.facebook.react.bridge.ReactApplicationContext
|
|
17
|
+
import com.facebook.react.bridge.ReadableMap
|
|
18
|
+
import com.facebook.react.bridge.UiThreadUtil
|
|
19
|
+
import com.facebook.react.bridge.WritableArray
|
|
20
|
+
import com.facebook.react.bridge.WritableMap
|
|
21
|
+
import com.facebook.react.bridge.WritableNativeArray
|
|
22
|
+
import java.io.ByteArrayOutputStream
|
|
23
|
+
import java.nio.ByteBuffer
|
|
24
|
+
import java.util.concurrent.CountDownLatch
|
|
25
|
+
import java.util.concurrent.TimeUnit
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* UI helper implementation for HarnessUI.
|
|
29
|
+
* Includes touch simulation and view querying capabilities.
|
|
30
|
+
*/
|
|
31
|
+
class UIHelperImpl(private val context: ReactApplicationContext) {
|
|
32
|
+
|
|
33
|
+
companion object {
|
|
34
|
+
private const val TAG = "HarnessUI"
|
|
35
|
+
private const val TAP_DURATION_MS = 50L // Duration between touch down and up
|
|
36
|
+
private const val EVENT_PROCESSING_DELAY_MS = 10L // Delay after touch up for React Native to process the event
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
private val mainHandler = Handler(Looper.getMainLooper())
|
|
40
|
+
|
|
41
|
+
// =========================================================================
|
|
42
|
+
// Touch Simulation
|
|
43
|
+
// =========================================================================
|
|
44
|
+
|
|
45
|
+
fun simulatePress(x: Double, y: Double, promise: Promise) {
|
|
46
|
+
Log.i(TAG, "simulatePress called with x:$x y:$y")
|
|
47
|
+
|
|
48
|
+
UiThreadUtil.runOnUiThread {
|
|
49
|
+
val activity = context.currentActivity ?: run {
|
|
50
|
+
Log.w(TAG, "No current activity")
|
|
51
|
+
promise.resolve(null)
|
|
52
|
+
return@runOnUiThread
|
|
53
|
+
}
|
|
54
|
+
val root = activity.window.decorView
|
|
55
|
+
|
|
56
|
+
// Convert DP to PX
|
|
57
|
+
val density = root.resources.displayMetrics.density
|
|
58
|
+
val pxX = (x * density).toFloat()
|
|
59
|
+
val pxY = (y * density).toFloat()
|
|
60
|
+
|
|
61
|
+
val downTime = SystemClock.uptimeMillis()
|
|
62
|
+
|
|
63
|
+
// 1. ACTION_DOWN
|
|
64
|
+
val downEvent = MotionEvent.obtain(downTime, downTime, MotionEvent.ACTION_DOWN, pxX, pxY, 0)
|
|
65
|
+
try {
|
|
66
|
+
root.dispatchTouchEvent(downEvent)
|
|
67
|
+
Log.i(TAG, "Sent touch down at ($pxX, $pxY)")
|
|
68
|
+
} finally {
|
|
69
|
+
downEvent.recycle()
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// 2. ACTION_UP after real delay to allow press feedback to render
|
|
73
|
+
mainHandler.postDelayed({
|
|
74
|
+
val upTime = SystemClock.uptimeMillis()
|
|
75
|
+
val upEvent = MotionEvent.obtain(downTime, upTime, MotionEvent.ACTION_UP, pxX, pxY, 0)
|
|
76
|
+
try {
|
|
77
|
+
root.dispatchTouchEvent(upEvent)
|
|
78
|
+
Log.i(TAG, "Tap completed at ($pxX, $pxY)")
|
|
79
|
+
} finally {
|
|
80
|
+
upEvent.recycle()
|
|
81
|
+
}
|
|
82
|
+
// Wait for React Native to process the touch event and trigger JS callbacks
|
|
83
|
+
mainHandler.postDelayed({
|
|
84
|
+
promise.resolve(null)
|
|
85
|
+
}, EVENT_PROCESSING_DELAY_MS)
|
|
86
|
+
}, TAP_DURATION_MS)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// =========================================================================
|
|
91
|
+
// Query API
|
|
92
|
+
// =========================================================================
|
|
93
|
+
|
|
94
|
+
fun queryByTestId(testId: String): WritableMap? {
|
|
95
|
+
Log.i(TAG, "queryByTestId called with: $testId")
|
|
96
|
+
return executeQuery(ViewQueryType.TEST_ID, testId)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
fun queryByAccessibilityLabel(label: String): WritableMap? {
|
|
100
|
+
Log.i(TAG, "queryByAccessibilityLabel called with: $label")
|
|
101
|
+
return executeQuery(ViewQueryType.ACCESSIBILITY_LABEL, label)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
fun queryAllByTestId(testId: String): WritableArray {
|
|
105
|
+
Log.i(TAG, "queryAllByTestId called with: $testId")
|
|
106
|
+
return executeQueryAll(ViewQueryType.TEST_ID, testId)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
fun queryAllByAccessibilityLabel(label: String): WritableArray {
|
|
110
|
+
Log.i(TAG, "queryAllByAccessibilityLabel called with: $label")
|
|
111
|
+
return executeQueryAll(ViewQueryType.ACCESSIBILITY_LABEL, label)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Executes a query on the UI thread and returns the result.
|
|
116
|
+
* Uses CountDownLatch to synchronize with the UI thread.
|
|
117
|
+
*/
|
|
118
|
+
private fun executeQuery(queryType: ViewQueryType, value: String): WritableMap? {
|
|
119
|
+
var result: WritableMap? = null
|
|
120
|
+
|
|
121
|
+
// If already on UI thread, execute directly
|
|
122
|
+
if (UiThreadUtil.isOnUiThread()) {
|
|
123
|
+
val activity = context.currentActivity ?: return null
|
|
124
|
+
result = ViewQueryHelper.query(activity, queryType, value)?.toWritableMap()
|
|
125
|
+
} else {
|
|
126
|
+
// Execute on UI thread and wait for result
|
|
127
|
+
val latch = CountDownLatch(1)
|
|
128
|
+
|
|
129
|
+
UiThreadUtil.runOnUiThread {
|
|
130
|
+
try {
|
|
131
|
+
val activity = context.currentActivity
|
|
132
|
+
if (activity != null) {
|
|
133
|
+
result = ViewQueryHelper.query(activity, queryType, value)?.toWritableMap()
|
|
134
|
+
}
|
|
135
|
+
} finally {
|
|
136
|
+
latch.countDown()
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Wait for UI thread with timeout
|
|
141
|
+
try {
|
|
142
|
+
latch.await(5, TimeUnit.SECONDS)
|
|
143
|
+
} catch (e: InterruptedException) {
|
|
144
|
+
Log.e(TAG, "Query interrupted", e)
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
Log.i(TAG, "Query result: $result")
|
|
149
|
+
return result
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Executes a query for all matching views on the UI thread.
|
|
154
|
+
* Uses CountDownLatch to synchronize with the UI thread.
|
|
155
|
+
*/
|
|
156
|
+
private fun executeQueryAll(queryType: ViewQueryType, value: String): WritableArray {
|
|
157
|
+
var result: WritableArray = Arguments.createArray()
|
|
158
|
+
|
|
159
|
+
// If already on UI thread, execute directly
|
|
160
|
+
if (UiThreadUtil.isOnUiThread()) {
|
|
161
|
+
val activity = context.currentActivity ?: return result
|
|
162
|
+
val queryResults = ViewQueryHelper.queryAll(activity, queryType, value)
|
|
163
|
+
result = Arguments.createArray().apply {
|
|
164
|
+
queryResults.forEach { pushMap(it.toWritableMap()) }
|
|
165
|
+
}
|
|
166
|
+
} else {
|
|
167
|
+
// Execute on UI thread and wait for result
|
|
168
|
+
val latch = CountDownLatch(1)
|
|
169
|
+
|
|
170
|
+
UiThreadUtil.runOnUiThread {
|
|
171
|
+
try {
|
|
172
|
+
val activity = context.currentActivity
|
|
173
|
+
if (activity != null) {
|
|
174
|
+
val queryResults = ViewQueryHelper.queryAll(activity, queryType, value)
|
|
175
|
+
result = Arguments.createArray().apply {
|
|
176
|
+
queryResults.forEach { pushMap(it.toWritableMap()) }
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
} finally {
|
|
180
|
+
latch.countDown()
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Wait for UI thread with timeout
|
|
185
|
+
try {
|
|
186
|
+
latch.await(5, TimeUnit.SECONDS)
|
|
187
|
+
} catch (e: InterruptedException) {
|
|
188
|
+
Log.e(TAG, "QueryAll interrupted", e)
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
Log.i(TAG, "QueryAll result count: ${result.size()}")
|
|
193
|
+
return result
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// =========================================================================
|
|
197
|
+
// Screenshot Capture
|
|
198
|
+
// =========================================================================
|
|
199
|
+
|
|
200
|
+
fun captureScreenshot(bounds: ReadableMap?, promise: Promise) {
|
|
201
|
+
Log.i(TAG, "captureScreenshot called")
|
|
202
|
+
|
|
203
|
+
UiThreadUtil.runOnUiThread {
|
|
204
|
+
val activity = context.currentActivity ?: run {
|
|
205
|
+
Log.w(TAG, "No current activity")
|
|
206
|
+
promise.resolve(null)
|
|
207
|
+
return@runOnUiThread
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
val root = activity.window.decorView.rootView
|
|
211
|
+
val density = root.resources.displayMetrics.density
|
|
212
|
+
|
|
213
|
+
try {
|
|
214
|
+
// Determine capture dimensions
|
|
215
|
+
val captureX: Int
|
|
216
|
+
val captureY: Int
|
|
217
|
+
val captureWidth: Int
|
|
218
|
+
val captureHeight: Int
|
|
219
|
+
|
|
220
|
+
// Check if bounds are provided and have non-zero dimensions
|
|
221
|
+
val hasBounds = bounds != null && bounds.getDouble("width") > 0 && bounds.getDouble("height") > 0
|
|
222
|
+
|
|
223
|
+
if (hasBounds) {
|
|
224
|
+
// Convert dp to px for bounds
|
|
225
|
+
captureX = (bounds!!.getDouble("x") * density).toInt()
|
|
226
|
+
captureY = (bounds.getDouble("y") * density).toInt()
|
|
227
|
+
captureWidth = (bounds.getDouble("width") * density).toInt()
|
|
228
|
+
captureHeight = (bounds.getDouble("height") * density).toInt()
|
|
229
|
+
Log.i(TAG, "Capturing region: x=$captureX y=$captureY w=$captureWidth h=$captureHeight")
|
|
230
|
+
} else {
|
|
231
|
+
captureX = 0
|
|
232
|
+
captureY = 0
|
|
233
|
+
captureWidth = root.width
|
|
234
|
+
captureHeight = root.height
|
|
235
|
+
Log.i(TAG, "Capturing full window: w=$captureWidth h=$captureHeight")
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Create bitmap and canvas
|
|
239
|
+
val bitmap = Bitmap.createBitmap(captureWidth, captureHeight, Bitmap.Config.ARGB_8888)
|
|
240
|
+
val canvas = Canvas(bitmap)
|
|
241
|
+
|
|
242
|
+
// Translate canvas if capturing a specific region
|
|
243
|
+
canvas.translate(-captureX.toFloat(), -captureY.toFloat())
|
|
244
|
+
|
|
245
|
+
// Draw the view hierarchy
|
|
246
|
+
root.draw(canvas)
|
|
247
|
+
|
|
248
|
+
// Convert to PNG bytes
|
|
249
|
+
val outputStream = ByteArrayOutputStream()
|
|
250
|
+
bitmap.compress(Bitmap.CompressFormat.PNG, 100, outputStream)
|
|
251
|
+
val pngBytes = outputStream.toByteArray()
|
|
252
|
+
|
|
253
|
+
// Clean up
|
|
254
|
+
bitmap.recycle()
|
|
255
|
+
|
|
256
|
+
// Return as Base64 string for efficiency
|
|
257
|
+
val base64String = android.util.Base64.encodeToString(pngBytes, android.util.Base64.NO_WRAP)
|
|
258
|
+
Log.i(TAG, "Screenshot captured successfully (${pngBytes.size} bytes)")
|
|
259
|
+
promise.resolve(base64String)
|
|
260
|
+
} catch (e: Exception) {
|
|
261
|
+
Log.e(TAG, "Screenshot capture failed", e)
|
|
262
|
+
promise.resolve(null)
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// =========================================================================
|
|
268
|
+
// Text Input
|
|
269
|
+
// =========================================================================
|
|
270
|
+
|
|
271
|
+
fun typeChar(character: String, promise: Promise) {
|
|
272
|
+
Log.i(TAG, "typeChar called with: $character")
|
|
273
|
+
|
|
274
|
+
UiThreadUtil.runOnUiThread {
|
|
275
|
+
val activity = context.currentActivity ?: run {
|
|
276
|
+
Log.w(TAG, "No current activity")
|
|
277
|
+
promise.resolve(null)
|
|
278
|
+
return@runOnUiThread
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
val focused = activity.currentFocus
|
|
282
|
+
if (focused is EditText) {
|
|
283
|
+
// Insert character at cursor position
|
|
284
|
+
val start = focused.selectionStart
|
|
285
|
+
val end = focused.selectionEnd
|
|
286
|
+
focused.text.replace(start, end, character)
|
|
287
|
+
Log.i(TAG, "Inserted character: $character")
|
|
288
|
+
} else {
|
|
289
|
+
Log.i(TAG, "No EditText is focused")
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Delay for React Native to process onChangeText
|
|
293
|
+
mainHandler.postDelayed({
|
|
294
|
+
promise.resolve(null)
|
|
295
|
+
}, EVENT_PROCESSING_DELAY_MS)
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
fun blur(options: ReadableMap, promise: Promise) {
|
|
300
|
+
val submitEditing = options.getBoolean("submitEditing")
|
|
301
|
+
Log.i(TAG, "blur called, submitEditing: $submitEditing")
|
|
302
|
+
|
|
303
|
+
UiThreadUtil.runOnUiThread {
|
|
304
|
+
val activity = context.currentActivity ?: run {
|
|
305
|
+
Log.w(TAG, "No current activity")
|
|
306
|
+
promise.resolve(null)
|
|
307
|
+
return@runOnUiThread
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
val focused = activity.currentFocus
|
|
311
|
+
|
|
312
|
+
if (submitEditing && focused is EditText) {
|
|
313
|
+
Log.i(TAG, "Triggering submitEditing")
|
|
314
|
+
// Trigger onSubmitEditing by simulating IME action
|
|
315
|
+
focused.onEditorAction(EditorInfo.IME_ACTION_DONE)
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (focused != null) {
|
|
319
|
+
// Clear focus (triggers onEndEditing and onBlur)
|
|
320
|
+
Log.i(TAG, "Clearing focus")
|
|
321
|
+
focused.clearFocus()
|
|
322
|
+
|
|
323
|
+
// Hide keyboard
|
|
324
|
+
val imm = activity.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
|
325
|
+
imm.hideSoftInputFromWindow(focused.windowToken, 0)
|
|
326
|
+
} else {
|
|
327
|
+
Log.i(TAG, "No view is focused")
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
mainHandler.postDelayed({
|
|
331
|
+
promise.resolve(null)
|
|
332
|
+
}, EVENT_PROCESSING_DELAY_MS)
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
package com.harnessui
|
|
2
|
+
|
|
3
|
+
import android.app.Activity
|
|
4
|
+
import android.view.View
|
|
5
|
+
import android.view.ViewGroup
|
|
6
|
+
import com.facebook.react.bridge.Arguments
|
|
7
|
+
import com.facebook.react.bridge.WritableMap
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Enum defining the types of queries that can be performed on the view hierarchy.
|
|
11
|
+
*/
|
|
12
|
+
enum class ViewQueryType {
|
|
13
|
+
TEST_ID,
|
|
14
|
+
ACCESSIBILITY_LABEL
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Represents information about a found view.
|
|
19
|
+
*/
|
|
20
|
+
data class ViewQueryResult(
|
|
21
|
+
val x: Float,
|
|
22
|
+
val y: Float,
|
|
23
|
+
val width: Float,
|
|
24
|
+
val height: Float
|
|
25
|
+
) {
|
|
26
|
+
fun toWritableMap(): WritableMap {
|
|
27
|
+
return Arguments.createMap().apply {
|
|
28
|
+
putDouble("x", x.toDouble())
|
|
29
|
+
putDouble("y", y.toDouble())
|
|
30
|
+
putDouble("width", width.toDouble())
|
|
31
|
+
putDouble("height", height.toDouble())
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Helper object for querying the view hierarchy.
|
|
38
|
+
* Provides reusable query logic for finding views by various criteria.
|
|
39
|
+
*/
|
|
40
|
+
object ViewQueryHelper {
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Finds the first view matching the query criteria.
|
|
44
|
+
* @param activity The current activity.
|
|
45
|
+
* @param queryType The type of query to perform.
|
|
46
|
+
* @param value The value to match against.
|
|
47
|
+
* @return ViewQueryResult if found, null otherwise.
|
|
48
|
+
*/
|
|
49
|
+
fun query(activity: Activity, queryType: ViewQueryType, value: String): ViewQueryResult? {
|
|
50
|
+
val root = activity.window.decorView
|
|
51
|
+
val density = root.resources.displayMetrics.density
|
|
52
|
+
val found = findViewInView(root, queryType, value) ?: return null
|
|
53
|
+
return resultFromView(found, density)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Finds all views matching the query criteria.
|
|
58
|
+
* @param activity The current activity.
|
|
59
|
+
* @param queryType The type of query to perform.
|
|
60
|
+
* @param value The value to match against.
|
|
61
|
+
* @return List of ViewQueryResult objects.
|
|
62
|
+
*/
|
|
63
|
+
fun queryAll(activity: Activity, queryType: ViewQueryType, value: String): List<ViewQueryResult> {
|
|
64
|
+
val root = activity.window.decorView
|
|
65
|
+
val density = root.resources.displayMetrics.density
|
|
66
|
+
val views = mutableListOf<View>()
|
|
67
|
+
findAllViewsInView(root, queryType, value, views)
|
|
68
|
+
return views.map { resultFromView(it, density) }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Checks if a view matches the given query criteria.
|
|
73
|
+
*/
|
|
74
|
+
private fun viewMatches(view: View, queryType: ViewQueryType, value: String): Boolean {
|
|
75
|
+
return when (queryType) {
|
|
76
|
+
ViewQueryType.TEST_ID -> view.tag == value
|
|
77
|
+
ViewQueryType.ACCESSIBILITY_LABEL -> view.contentDescription?.toString() == value
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Recursively finds the first view matching the query criteria.
|
|
83
|
+
*/
|
|
84
|
+
private fun findViewInView(view: View, queryType: ViewQueryType, value: String): View? {
|
|
85
|
+
if (viewMatches(view, queryType, value)) {
|
|
86
|
+
return view
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (view is ViewGroup) {
|
|
90
|
+
for (i in 0 until view.childCount) {
|
|
91
|
+
val found = findViewInView(view.getChildAt(i), queryType, value)
|
|
92
|
+
if (found != null) {
|
|
93
|
+
return found
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return null
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Recursively finds all views matching the query criteria.
|
|
102
|
+
*/
|
|
103
|
+
private fun findAllViewsInView(
|
|
104
|
+
view: View,
|
|
105
|
+
queryType: ViewQueryType,
|
|
106
|
+
value: String,
|
|
107
|
+
results: MutableList<View>
|
|
108
|
+
) {
|
|
109
|
+
if (viewMatches(view, queryType, value)) {
|
|
110
|
+
results.add(view)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (view is ViewGroup) {
|
|
114
|
+
for (i in 0 until view.childCount) {
|
|
115
|
+
findAllViewsInView(view.getChildAt(i), queryType, value, results)
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Converts a View to a ViewQueryResult with screen coordinates in dp.
|
|
122
|
+
*/
|
|
123
|
+
private fun resultFromView(view: View, density: Float): ViewQueryResult {
|
|
124
|
+
val location = IntArray(2)
|
|
125
|
+
view.getLocationOnScreen(location)
|
|
126
|
+
|
|
127
|
+
// Convert pixels to dp
|
|
128
|
+
val x = location[0] / density
|
|
129
|
+
val y = location[1] / density
|
|
130
|
+
val width = view.width / density
|
|
131
|
+
val height = view.height / density
|
|
132
|
+
|
|
133
|
+
return ViewQueryResult(x, y, width, height)
|
|
134
|
+
}
|
|
135
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { type TurboModule } from 'react-native';
|
|
2
|
+
/**
|
|
3
|
+
* Represents the position and dimensions of a view in screen coordinates (points/dp).
|
|
4
|
+
*/
|
|
5
|
+
export interface ViewInfo {
|
|
6
|
+
x: number;
|
|
7
|
+
y: number;
|
|
8
|
+
width: number;
|
|
9
|
+
height: number;
|
|
10
|
+
}
|
|
11
|
+
interface Spec extends TurboModule {
|
|
12
|
+
/**
|
|
13
|
+
* Simulates a native press at the specified screen coordinates.
|
|
14
|
+
* Returns a promise that resolves when the press action is complete.
|
|
15
|
+
*/
|
|
16
|
+
simulatePress(x: number, y: number): Promise<void>;
|
|
17
|
+
/**
|
|
18
|
+
* Finds a view by its testID (accessibilityIdentifier on iOS, tag on Android).
|
|
19
|
+
* Returns null if no view is found.
|
|
20
|
+
*/
|
|
21
|
+
queryByTestId(testId: string): ViewInfo | null;
|
|
22
|
+
/**
|
|
23
|
+
* Finds all views by testID (accessibilityIdentifier on iOS, tag on Android).
|
|
24
|
+
* Returns an empty array if no views are found.
|
|
25
|
+
*/
|
|
26
|
+
queryAllByTestId(testId: string): ViewInfo[];
|
|
27
|
+
/**
|
|
28
|
+
* Finds a view by its accessibility label.
|
|
29
|
+
* Returns null if no view is found.
|
|
30
|
+
*/
|
|
31
|
+
queryByAccessibilityLabel(label: string): ViewInfo | null;
|
|
32
|
+
/**
|
|
33
|
+
* Finds all views by accessibility label.
|
|
34
|
+
* Returns an empty array if no views are found.
|
|
35
|
+
*/
|
|
36
|
+
queryAllByAccessibilityLabel(label: string): ViewInfo[];
|
|
37
|
+
/**
|
|
38
|
+
* Captures a screenshot of the app UI.
|
|
39
|
+
* @param bounds Optional bounds to capture a specific region. Pass null to capture the entire window.
|
|
40
|
+
* @returns Promise resolving to Base64 encoded string containing PNG data, or null on failure.
|
|
41
|
+
*/
|
|
42
|
+
captureScreenshot(bounds: ViewInfo | null): Promise<string | null>;
|
|
43
|
+
/**
|
|
44
|
+
* Types a single character into the currently focused text input.
|
|
45
|
+
* If no text input is focused, this is a no-op.
|
|
46
|
+
*/
|
|
47
|
+
typeChar(character: string): Promise<void>;
|
|
48
|
+
/**
|
|
49
|
+
* Blurs (resigns first responder from) the currently focused element.
|
|
50
|
+
* Optionally triggers submitEditing event before blur.
|
|
51
|
+
*/
|
|
52
|
+
blur(options: {
|
|
53
|
+
submitEditing?: boolean;
|
|
54
|
+
}): Promise<void>;
|
|
55
|
+
}
|
|
56
|
+
export type HarnessUIModule = Spec;
|
|
57
|
+
declare const _default: Spec;
|
|
58
|
+
export default _default;
|
|
59
|
+
//# sourceMappingURL=NativeHarnessUI.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
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,QAAQ;IACvB,CAAC,EAAE,MAAM,CAAC;IACV,CAAC,EAAE,MAAM,CAAC;IACV,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,UAAU,IAAK,SAAQ,WAAW;IAChC;;;OAGG;IACH,aAAa,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAEnD;;;OAGG;IACH,aAAa,CAAC,MAAM,EAAE,MAAM,GAAG,QAAQ,GAAG,IAAI,CAAC;IAE/C;;;OAGG;IACH,gBAAgB,CAAC,MAAM,EAAE,MAAM,GAAG,QAAQ,EAAE,CAAC;IAE7C;;;OAGG;IACH,yBAAyB,CAAC,KAAK,EAAE,MAAM,GAAG,QAAQ,GAAG,IAAI,CAAC;IAE1D;;;OAGG;IACH,4BAA4B,CAAC,KAAK,EAAE,MAAM,GAAG,QAAQ,EAAE,CAAC;IAExD;;;;OAIG;IACH,iBAAiB,CAAC,MAAM,EAAE,QAAQ,GAAG,IAAI,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IAEnE;;;OAGG;IACH,QAAQ,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAE3C;;;OAGG;IACH,IAAI,CAAC,OAAO,EAAE;QAAE,aAAa,CAAC,EAAE,OAAO,CAAA;KAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CAC3D;AAED,MAAM,MAAM,eAAe,GAAG,IAAI,CAAC;;AAEnC,wBAAmE"}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @react-native-harness/ui
|
|
3
|
+
*
|
|
4
|
+
* Native UI testing module for React Native Harness.
|
|
5
|
+
* Provides view queries and touch simulation.
|
|
6
|
+
*
|
|
7
|
+
* This module is automatically excluded from release builds
|
|
8
|
+
* and only available in debug builds.
|
|
9
|
+
*/
|
|
10
|
+
export { screen, type Screen, type ElementReference, type ScreenshotResult, } from './screen.js';
|
|
11
|
+
export { userEvent, type UserEvent } from './userEvent.js';
|
|
12
|
+
export type { ViewInfo } from './NativeHarnessUI.js';
|
|
13
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,EACL,MAAM,EACN,KAAK,MAAM,EACX,KAAK,gBAAgB,EACrB,KAAK,gBAAgB,GACtB,MAAM,aAAa,CAAC;AACrB,OAAO,EAAE,SAAS,EAAE,KAAK,SAAS,EAAE,MAAM,gBAAgB,CAAC;AAC3D,YAAY,EAAE,QAAQ,EAAE,MAAM,sBAAsB,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @react-native-harness/ui
|
|
3
|
+
*
|
|
4
|
+
* Native UI testing module for React Native Harness.
|
|
5
|
+
* Provides view queries and touch simulation.
|
|
6
|
+
*
|
|
7
|
+
* This module is automatically excluded from release builds
|
|
8
|
+
* and only available in debug builds.
|
|
9
|
+
*/
|
|
10
|
+
export { screen, } from './screen.js';
|
|
11
|
+
export { userEvent } from './userEvent.js';
|
package/dist/screen.d.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { type ViewInfo } from './NativeHarnessUI.js';
|
|
2
|
+
/**
|
|
3
|
+
* Represents an element found on screen with its position and dimensions.
|
|
4
|
+
* This can be used with userEvent.press() to interact with the element.
|
|
5
|
+
*/
|
|
6
|
+
export type ElementReference = ViewInfo;
|
|
7
|
+
/**
|
|
8
|
+
* Screenshot result containing PNG image data.
|
|
9
|
+
*/
|
|
10
|
+
export interface ScreenshotResult {
|
|
11
|
+
/** PNG image data as Uint8Array (ArrayBuffer view) */
|
|
12
|
+
data: Uint8Array;
|
|
13
|
+
/** Width of the captured image in logical pixels (points/dp) */
|
|
14
|
+
width: number;
|
|
15
|
+
/** Height of the captured image in logical pixels (points/dp) */
|
|
16
|
+
height: number;
|
|
17
|
+
}
|
|
18
|
+
export type Screen = {
|
|
19
|
+
/**
|
|
20
|
+
* Finds an element by its testID (accessibilityIdentifier on iOS, tag on Android).
|
|
21
|
+
* @throws Error if no element is found with the given testID.
|
|
22
|
+
*/
|
|
23
|
+
findByTestId: (testId: string) => Promise<ElementReference>;
|
|
24
|
+
/**
|
|
25
|
+
* Finds all elements by testID (accessibilityIdentifier on iOS, tag on Android).
|
|
26
|
+
* @throws Error if no elements are found with the given testID.
|
|
27
|
+
*/
|
|
28
|
+
findAllByTestId: (testId: string) => Promise<ElementReference[]>;
|
|
29
|
+
/**
|
|
30
|
+
* Queries for an element by its testID without throwing.
|
|
31
|
+
* Returns null if no element is found.
|
|
32
|
+
*/
|
|
33
|
+
queryByTestId: (testId: string) => ElementReference | null;
|
|
34
|
+
/**
|
|
35
|
+
* Queries for all elements by testID without throwing.
|
|
36
|
+
* Returns an empty array if no elements are found.
|
|
37
|
+
*/
|
|
38
|
+
queryAllByTestId: (testId: string) => ElementReference[];
|
|
39
|
+
/**
|
|
40
|
+
* Finds an element by its accessibility label.
|
|
41
|
+
* @throws Error if no element is found with the given label.
|
|
42
|
+
*/
|
|
43
|
+
findByAccessibilityLabel: (label: string) => Promise<ElementReference>;
|
|
44
|
+
/**
|
|
45
|
+
* Finds all elements by accessibility label.
|
|
46
|
+
* @throws Error if no elements are found with the given label.
|
|
47
|
+
*/
|
|
48
|
+
findAllByAccessibilityLabel: (label: string) => Promise<ElementReference[]>;
|
|
49
|
+
/**
|
|
50
|
+
* Queries for an element by its accessibility label without throwing.
|
|
51
|
+
* Returns null if no element is found.
|
|
52
|
+
*/
|
|
53
|
+
queryByAccessibilityLabel: (label: string) => ElementReference | null;
|
|
54
|
+
/**
|
|
55
|
+
* Queries for all elements by accessibility label without throwing.
|
|
56
|
+
* Returns an empty array if no elements are found.
|
|
57
|
+
*/
|
|
58
|
+
queryAllByAccessibilityLabel: (label: string) => ElementReference[];
|
|
59
|
+
/**
|
|
60
|
+
* Captures a screenshot of the entire app window or a specific element.
|
|
61
|
+
* @param element Optional element reference to capture. If not provided, captures the entire window.
|
|
62
|
+
* @returns Promise resolving to ScreenshotResult with PNG data, or null if capture fails.
|
|
63
|
+
*/
|
|
64
|
+
screenshot: (element?: ElementReference) => Promise<ScreenshotResult | null>;
|
|
65
|
+
};
|
|
66
|
+
export declare const screen: Screen;
|
|
67
|
+
//# sourceMappingURL=screen.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"screen.d.ts","sourceRoot":"","sources":["../src/screen.ts"],"names":[],"mappings":"AAAA,OAAwB,EAAE,KAAK,QAAQ,EAAE,MAAM,sBAAsB,CAAC;AAGtE;;;GAGG;AACH,MAAM,MAAM,gBAAgB,GAAG,QAAQ,CAAC;AAExC;;GAEG;AACH,MAAM,WAAW,gBAAgB;IAC/B,sDAAsD;IACtD,IAAI,EAAE,UAAU,CAAC;IACjB,gEAAgE;IAChE,KAAK,EAAE,MAAM,CAAC;IACd,iEAAiE;IACjE,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,MAAM,MAAM,GAAG;IACnB;;;OAGG;IACH,YAAY,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,OAAO,CAAC,gBAAgB,CAAC,CAAC;IAE5D;;;OAGG;IACH,eAAe,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,OAAO,CAAC,gBAAgB,EAAE,CAAC,CAAC;IAEjE;;;OAGG;IACH,aAAa,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,gBAAgB,GAAG,IAAI,CAAC;IAE3D;;;OAGG;IACH,gBAAgB,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,gBAAgB,EAAE,CAAC;IAEzD;;;OAGG;IACH,wBAAwB,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,OAAO,CAAC,gBAAgB,CAAC,CAAC;IAEvE;;;OAGG;IACH,2BAA2B,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,OAAO,CAAC,gBAAgB,EAAE,CAAC,CAAC;IAE5E;;;OAGG;IACH,yBAAyB,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,gBAAgB,GAAG,IAAI,CAAC;IAEtE;;;OAGG;IACH,4BAA4B,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,gBAAgB,EAAE,CAAC;IAEpE;;;;OAIG;IACH,UAAU,EAAE,CAAC,OAAO,CAAC,EAAE,gBAAgB,KAAK,OAAO,CAAC,gBAAgB,GAAG,IAAI,CAAC,CAAC;CAC9E,CAAC;AAkGF,eAAO,MAAM,MAAM,QAAiB,CAAC"}
|