@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
package/ios/HarnessUI.mm
ADDED
|
@@ -0,0 +1,526 @@
|
|
|
1
|
+
#import "HarnessUI.h"
|
|
2
|
+
#import "ViewQueryHelper.h"
|
|
3
|
+
#import <QuartzCore/QuartzCore.h>
|
|
4
|
+
#import <React/RCTBridgeModule.h>
|
|
5
|
+
#import <React/RCTLog.h>
|
|
6
|
+
#import <UIKit/UIKit.h>
|
|
7
|
+
#import <objc/message.h>
|
|
8
|
+
#import <objc/runtime.h>
|
|
9
|
+
|
|
10
|
+
// Private UITouch methods for touch synthesis
|
|
11
|
+
@interface UITouch (Synthesis)
|
|
12
|
+
- (void)setPhase:(UITouchPhase)phase;
|
|
13
|
+
- (void)setTapCount:(NSUInteger)tapCount;
|
|
14
|
+
- (void)setIsTap:(BOOL)isTap;
|
|
15
|
+
- (void)setTimestamp:(NSTimeInterval)timestamp;
|
|
16
|
+
- (void)setWindow:(UIWindow *)window;
|
|
17
|
+
- (void)setView:(UIView *)view;
|
|
18
|
+
- (void)_setLocationInWindow:(CGPoint)location resetPrevious:(BOOL)reset;
|
|
19
|
+
- (void)_setIsFirstTouchForView:(BOOL)first;
|
|
20
|
+
- (void)_setTouchIdentifier:(unsigned int)identifier;
|
|
21
|
+
@end
|
|
22
|
+
|
|
23
|
+
// Private UIEvent methods for event synthesis
|
|
24
|
+
@interface UIEvent (Synthesis)
|
|
25
|
+
- (void)_addTouch:(UITouch *)touch forDelayedDelivery:(BOOL)delayed;
|
|
26
|
+
- (void)_clearTouches;
|
|
27
|
+
- (void)_setTimestamp:(NSTimeInterval)timestamp;
|
|
28
|
+
- (void)_setHIDEvent:(void *)event;
|
|
29
|
+
@end
|
|
30
|
+
|
|
31
|
+
// Private UIApplication method for event synthesis
|
|
32
|
+
@interface UIApplication (Synthesis)
|
|
33
|
+
- (UIEvent *)_touchesEvent;
|
|
34
|
+
@end
|
|
35
|
+
|
|
36
|
+
// Configuration constants
|
|
37
|
+
static const int kMaxRetryCount =
|
|
38
|
+
10; // Maximum retries waiting for active touches
|
|
39
|
+
static const NSTimeInterval kRetryInterval = 0.05; // 50ms between retries
|
|
40
|
+
static const NSTimeInterval kSettleDelay =
|
|
41
|
+
0.01; // 10ms delay for system to settle
|
|
42
|
+
static const NSTimeInterval kTapDuration =
|
|
43
|
+
0.05; // 50ms between touch began and ended
|
|
44
|
+
static const NSTimeInterval kEventProcessingDelay =
|
|
45
|
+
0.01; // 10ms delay after touch ended for React Native to process the event
|
|
46
|
+
static const NSTimeInterval kMinTapInterval =
|
|
47
|
+
0.15; // 150ms minimum between taps
|
|
48
|
+
|
|
49
|
+
static unsigned int _touchIdCounter = 100;
|
|
50
|
+
static NSTimeInterval _lastTapTime = 0;
|
|
51
|
+
|
|
52
|
+
@implementation HarnessUI
|
|
53
|
+
|
|
54
|
+
#pragma mark - Window Access
|
|
55
|
+
|
|
56
|
+
- (UIWindow *)getActiveWindow {
|
|
57
|
+
return [ViewQueryHelper getActiveWindow];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
#pragma mark - Touch Synthesis
|
|
61
|
+
|
|
62
|
+
- (void)sendTouchEvent:(UITouch *)touch
|
|
63
|
+
event:(UIEvent *)event
|
|
64
|
+
phase:(UITouchPhase)phase
|
|
65
|
+
point:(CGPoint)point {
|
|
66
|
+
NSTimeInterval timestamp = [[NSProcessInfo processInfo] systemUptime];
|
|
67
|
+
|
|
68
|
+
NSString *phaseName;
|
|
69
|
+
switch (phase) {
|
|
70
|
+
case UITouchPhaseBegan:
|
|
71
|
+
phaseName = @"began";
|
|
72
|
+
break;
|
|
73
|
+
case UITouchPhaseMoved:
|
|
74
|
+
phaseName = @"moved";
|
|
75
|
+
break;
|
|
76
|
+
case UITouchPhaseStationary:
|
|
77
|
+
phaseName = @"stationary";
|
|
78
|
+
break;
|
|
79
|
+
case UITouchPhaseEnded:
|
|
80
|
+
phaseName = @"ended";
|
|
81
|
+
break;
|
|
82
|
+
case UITouchPhaseCancelled:
|
|
83
|
+
phaseName = @"cancelled";
|
|
84
|
+
break;
|
|
85
|
+
default:
|
|
86
|
+
phaseName = @"unknown";
|
|
87
|
+
}
|
|
88
|
+
RCTLogInfo(@"[HarnessUI] Sending touch event: phase=%@ point=(%.2f, %.2f)",
|
|
89
|
+
phaseName, point.x, point.y);
|
|
90
|
+
|
|
91
|
+
[touch setPhase:phase];
|
|
92
|
+
[touch setTimestamp:timestamp];
|
|
93
|
+
[touch _setLocationInWindow:point resetPrevious:(phase == UITouchPhaseBegan)];
|
|
94
|
+
|
|
95
|
+
[event _clearTouches];
|
|
96
|
+
[event _addTouch:touch forDelayedDelivery:NO];
|
|
97
|
+
[event _setTimestamp:timestamp];
|
|
98
|
+
|
|
99
|
+
[[UIApplication sharedApplication] sendEvent:event];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
- (BOOL)hasActiveTouches:(UIWindow *)window {
|
|
103
|
+
// Check if there are any active touches on the window
|
|
104
|
+
UIEvent *event = [[UIApplication sharedApplication] _touchesEvent];
|
|
105
|
+
NSSet *allTouches = [event allTouches];
|
|
106
|
+
|
|
107
|
+
for (UITouch *touch in allTouches) {
|
|
108
|
+
UITouchPhase phase = touch.phase;
|
|
109
|
+
if (phase == UITouchPhaseBegan || phase == UITouchPhaseMoved ||
|
|
110
|
+
phase == UITouchPhaseStationary) {
|
|
111
|
+
RCTLogInfo(@"[HarnessUI] Found active touch in phase %ld", (long)phase);
|
|
112
|
+
return YES;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return NO;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
- (void)executeTapAtPoint:(CGPoint)point
|
|
119
|
+
retryCount:(int)retryCount
|
|
120
|
+
completion:(void (^)(void))completion {
|
|
121
|
+
UIWindow *window = [self getActiveWindow];
|
|
122
|
+
if (!window) {
|
|
123
|
+
RCTLogInfo(@"[HarnessUI] No active window found");
|
|
124
|
+
if (completion)
|
|
125
|
+
completion();
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Check for active real touches - wait for them to finish
|
|
130
|
+
if ([self hasActiveTouches:window]) {
|
|
131
|
+
if (retryCount < kMaxRetryCount) {
|
|
132
|
+
RCTLogInfo(@"[HarnessUI] Active touches detected, waiting... (retry %d)",
|
|
133
|
+
retryCount);
|
|
134
|
+
dispatch_after(dispatch_time(DISPATCH_TIME_NOW,
|
|
135
|
+
(int64_t)(kRetryInterval * NSEC_PER_SEC)),
|
|
136
|
+
dispatch_get_main_queue(), ^{
|
|
137
|
+
[self executeTapAtPoint:point
|
|
138
|
+
retryCount:retryCount + 1
|
|
139
|
+
completion:completion];
|
|
140
|
+
});
|
|
141
|
+
return;
|
|
142
|
+
} else {
|
|
143
|
+
RCTLogInfo(
|
|
144
|
+
@"[HarnessUI] Timeout waiting for touches to end, proceeding anyway");
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
UIView *targetView = [window hitTest:point withEvent:nil];
|
|
149
|
+
if (!targetView) {
|
|
150
|
+
RCTLogInfo(@"[HarnessUI] No view at point (%.2f, %.2f)", point.x, point.y);
|
|
151
|
+
if (completion)
|
|
152
|
+
completion();
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
RCTLogInfo(@"[HarnessUI] Target view: %@", targetView);
|
|
157
|
+
|
|
158
|
+
// Get the internal touches event from UIApplication
|
|
159
|
+
UIEvent *event = [[UIApplication sharedApplication] _touchesEvent];
|
|
160
|
+
|
|
161
|
+
// Aggressively clear all existing state
|
|
162
|
+
[event _clearTouches];
|
|
163
|
+
if ([event respondsToSelector:@selector(_setHIDEvent:)]) {
|
|
164
|
+
[event _setHIDEvent:NULL];
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Small delay after clearing to let the system settle
|
|
168
|
+
dispatch_after(
|
|
169
|
+
dispatch_time(DISPATCH_TIME_NOW, (int64_t)(kSettleDelay * NSEC_PER_SEC)),
|
|
170
|
+
dispatch_get_main_queue(), ^{
|
|
171
|
+
// Create fresh touch with unique identifier (handle overflow)
|
|
172
|
+
UITouch *touch = [[UITouch alloc] init];
|
|
173
|
+
_touchIdCounter =
|
|
174
|
+
(_touchIdCounter >= UINT_MAX - 1) ? 100 : _touchIdCounter + 1;
|
|
175
|
+
unsigned int touchId = _touchIdCounter;
|
|
176
|
+
|
|
177
|
+
if ([touch respondsToSelector:@selector(_setTouchIdentifier:)]) {
|
|
178
|
+
[touch _setTouchIdentifier:touchId];
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Configure touch
|
|
182
|
+
[touch setWindow:window];
|
|
183
|
+
[touch setView:targetView];
|
|
184
|
+
[touch setTapCount:1];
|
|
185
|
+
if ([touch respondsToSelector:@selector(_setIsFirstTouchForView:)]) {
|
|
186
|
+
[touch _setIsFirstTouchForView:YES];
|
|
187
|
+
}
|
|
188
|
+
if ([touch respondsToSelector:@selector(setIsTap:)]) {
|
|
189
|
+
[touch setIsTap:YES];
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Re-fetch event (in case it changed) and clear again
|
|
193
|
+
UIEvent *freshEvent = [[UIApplication sharedApplication] _touchesEvent];
|
|
194
|
+
[freshEvent _clearTouches];
|
|
195
|
+
|
|
196
|
+
// Send began
|
|
197
|
+
[self sendTouchEvent:touch
|
|
198
|
+
event:freshEvent
|
|
199
|
+
phase:UITouchPhaseBegan
|
|
200
|
+
point:point];
|
|
201
|
+
RCTLogInfo(@"[HarnessUI] Sent touch began (id=%u)", touchId);
|
|
202
|
+
|
|
203
|
+
// Schedule ended phase - re-fetch event to avoid race condition with
|
|
204
|
+
// real touches
|
|
205
|
+
dispatch_after(
|
|
206
|
+
dispatch_time(DISPATCH_TIME_NOW,
|
|
207
|
+
(int64_t)(kTapDuration * NSEC_PER_SEC)),
|
|
208
|
+
dispatch_get_main_queue(), ^{
|
|
209
|
+
UIEvent *endEvent =
|
|
210
|
+
[[UIApplication sharedApplication] _touchesEvent];
|
|
211
|
+
[self sendTouchEvent:touch
|
|
212
|
+
event:endEvent
|
|
213
|
+
phase:UITouchPhaseEnded
|
|
214
|
+
point:point];
|
|
215
|
+
[endEvent _clearTouches];
|
|
216
|
+
RCTLogInfo(@"[HarnessUI] Tap completed (id=%u)", touchId);
|
|
217
|
+
// Wait for React Native to process the touch event
|
|
218
|
+
// and trigger JS callbacks before resolving
|
|
219
|
+
dispatch_after(dispatch_time(DISPATCH_TIME_NOW,
|
|
220
|
+
(int64_t)(kEventProcessingDelay *
|
|
221
|
+
NSEC_PER_SEC)),
|
|
222
|
+
dispatch_get_main_queue(), ^{
|
|
223
|
+
if (completion)
|
|
224
|
+
completion();
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
- (void)executeTapAtPoint:(CGPoint)point completion:(void (^)(void))completion {
|
|
231
|
+
[self executeTapAtPoint:point retryCount:0 completion:completion];
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
- (void)simulatePress:(double)x
|
|
235
|
+
y:(double)y
|
|
236
|
+
resolve:(RCTPromiseResolveBlock)resolve
|
|
237
|
+
reject:(RCTPromiseRejectBlock)reject {
|
|
238
|
+
RCTLogInfo(@"[HarnessUI] simulatePress called with x:%.2f y:%.2f", x, y);
|
|
239
|
+
|
|
240
|
+
dispatch_async(dispatch_get_main_queue(), ^{
|
|
241
|
+
NSTimeInterval now = CACurrentMediaTime();
|
|
242
|
+
NSTimeInterval timeSinceLastTap = now - _lastTapTime;
|
|
243
|
+
CGPoint point = CGPointMake(x, y);
|
|
244
|
+
|
|
245
|
+
// Completion block that actually resolves the promise
|
|
246
|
+
void (^completionBlock)(void) = ^{
|
|
247
|
+
// Check if tap was successful by verifying we found a window/view
|
|
248
|
+
UIWindow *window = [self getActiveWindow];
|
|
249
|
+
if (!window) {
|
|
250
|
+
reject(@"NO_WINDOW", @"No active window found", nil);
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
UIView *targetView = [window hitTest:point withEvent:nil];
|
|
255
|
+
if (!targetView) {
|
|
256
|
+
reject(
|
|
257
|
+
@"NO_VIEW",
|
|
258
|
+
[NSString
|
|
259
|
+
stringWithFormat:@"No view found at point (%.2f, %.2f)", x, y],
|
|
260
|
+
nil);
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Success - resolve with success info
|
|
265
|
+
resolve(@{@"success" : @YES, @"x" : @(x), @"y" : @(y)});
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
if (timeSinceLastTap < kMinTapInterval) {
|
|
269
|
+
NSTimeInterval delay = kMinTapInterval - timeSinceLastTap;
|
|
270
|
+
RCTLogInfo(@"[HarnessUI] Delaying tap by %.0fms", delay * 1000);
|
|
271
|
+
_lastTapTime = now + delay;
|
|
272
|
+
|
|
273
|
+
dispatch_after(
|
|
274
|
+
dispatch_time(DISPATCH_TIME_NOW, (int64_t)(delay * NSEC_PER_SEC)),
|
|
275
|
+
dispatch_get_main_queue(), ^{
|
|
276
|
+
[self executeTapAtPoint:point completion:completionBlock];
|
|
277
|
+
});
|
|
278
|
+
} else {
|
|
279
|
+
_lastTapTime = now;
|
|
280
|
+
[self executeTapAtPoint:point completion:completionBlock];
|
|
281
|
+
}
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
#pragma mark - Query API
|
|
286
|
+
|
|
287
|
+
- (NSDictionary *)queryByTestId:(NSString *)testId {
|
|
288
|
+
RCTLogInfo(@"[HarnessUI] queryByTestId called with: %@", testId);
|
|
289
|
+
|
|
290
|
+
__block NSDictionary *result = nil;
|
|
291
|
+
|
|
292
|
+
if ([NSThread isMainThread]) {
|
|
293
|
+
ViewQueryResult *queryResult =
|
|
294
|
+
[ViewQueryHelper queryWithType:ViewQueryTypeTestId value:testId];
|
|
295
|
+
result = queryResult ? [queryResult toDictionary] : nil;
|
|
296
|
+
} else {
|
|
297
|
+
dispatch_sync(dispatch_get_main_queue(), ^{
|
|
298
|
+
ViewQueryResult *queryResult =
|
|
299
|
+
[ViewQueryHelper queryWithType:ViewQueryTypeTestId value:testId];
|
|
300
|
+
result = queryResult ? [queryResult toDictionary] : nil;
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
RCTLogInfo(@"[HarnessUI] queryByTestId result: %@", result);
|
|
305
|
+
return result;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
- (NSDictionary *)queryByAccessibilityLabel:(NSString *)label {
|
|
309
|
+
RCTLogInfo(@"[HarnessUI] queryByAccessibilityLabel called with: %@", label);
|
|
310
|
+
|
|
311
|
+
__block NSDictionary *result = nil;
|
|
312
|
+
|
|
313
|
+
if ([NSThread isMainThread]) {
|
|
314
|
+
ViewQueryResult *queryResult =
|
|
315
|
+
[ViewQueryHelper queryWithType:ViewQueryTypeAccessibilityLabel
|
|
316
|
+
value:label];
|
|
317
|
+
result = queryResult ? [queryResult toDictionary] : nil;
|
|
318
|
+
} else {
|
|
319
|
+
dispatch_sync(dispatch_get_main_queue(), ^{
|
|
320
|
+
ViewQueryResult *queryResult =
|
|
321
|
+
[ViewQueryHelper queryWithType:ViewQueryTypeAccessibilityLabel
|
|
322
|
+
value:label];
|
|
323
|
+
result = queryResult ? [queryResult toDictionary] : nil;
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
RCTLogInfo(@"[HarnessUI] queryByAccessibilityLabel result: %@", result);
|
|
328
|
+
return result;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
- (NSArray<NSDictionary *> *)queryAllByTestId:(NSString *)testId {
|
|
332
|
+
RCTLogInfo(@"[HarnessUI] queryAllByTestId called with: %@", testId);
|
|
333
|
+
|
|
334
|
+
__block NSArray<NSDictionary *> *result = @[];
|
|
335
|
+
|
|
336
|
+
if ([NSThread isMainThread]) {
|
|
337
|
+
NSArray<ViewQueryResult *> *queryResults =
|
|
338
|
+
[ViewQueryHelper queryAllWithType:ViewQueryTypeTestId value:testId];
|
|
339
|
+
NSMutableArray<NSDictionary *> *dicts =
|
|
340
|
+
[NSMutableArray arrayWithCapacity:queryResults.count];
|
|
341
|
+
for (ViewQueryResult *queryResult in queryResults) {
|
|
342
|
+
[dicts addObject:[queryResult toDictionary]];
|
|
343
|
+
}
|
|
344
|
+
result = dicts;
|
|
345
|
+
} else {
|
|
346
|
+
dispatch_sync(dispatch_get_main_queue(), ^{
|
|
347
|
+
NSArray<ViewQueryResult *> *queryResults =
|
|
348
|
+
[ViewQueryHelper queryAllWithType:ViewQueryTypeTestId value:testId];
|
|
349
|
+
NSMutableArray<NSDictionary *> *dicts =
|
|
350
|
+
[NSMutableArray arrayWithCapacity:queryResults.count];
|
|
351
|
+
for (ViewQueryResult *queryResult in queryResults) {
|
|
352
|
+
[dicts addObject:[queryResult toDictionary]];
|
|
353
|
+
}
|
|
354
|
+
result = dicts;
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
RCTLogInfo(@"[HarnessUI] queryAllByTestId result count: %lu",
|
|
359
|
+
(unsigned long)result.count);
|
|
360
|
+
return result;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
- (NSArray<NSDictionary *> *)queryAllByAccessibilityLabel:(NSString *)label {
|
|
364
|
+
RCTLogInfo(@"[HarnessUI] queryAllByAccessibilityLabel called with: %@", label);
|
|
365
|
+
|
|
366
|
+
__block NSArray<NSDictionary *> *result = @[];
|
|
367
|
+
|
|
368
|
+
if ([NSThread isMainThread]) {
|
|
369
|
+
NSArray<ViewQueryResult *> *queryResults =
|
|
370
|
+
[ViewQueryHelper queryAllWithType:ViewQueryTypeAccessibilityLabel
|
|
371
|
+
value:label];
|
|
372
|
+
NSMutableArray<NSDictionary *> *dicts =
|
|
373
|
+
[NSMutableArray arrayWithCapacity:queryResults.count];
|
|
374
|
+
for (ViewQueryResult *queryResult in queryResults) {
|
|
375
|
+
[dicts addObject:[queryResult toDictionary]];
|
|
376
|
+
}
|
|
377
|
+
result = dicts;
|
|
378
|
+
} else {
|
|
379
|
+
dispatch_sync(dispatch_get_main_queue(), ^{
|
|
380
|
+
NSArray<ViewQueryResult *> *queryResults =
|
|
381
|
+
[ViewQueryHelper queryAllWithType:ViewQueryTypeAccessibilityLabel
|
|
382
|
+
value:label];
|
|
383
|
+
NSMutableArray<NSDictionary *> *dicts =
|
|
384
|
+
[NSMutableArray arrayWithCapacity:queryResults.count];
|
|
385
|
+
for (ViewQueryResult *queryResult in queryResults) {
|
|
386
|
+
[dicts addObject:[queryResult toDictionary]];
|
|
387
|
+
}
|
|
388
|
+
result = dicts;
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
RCTLogInfo(@"[HarnessUI] queryAllByAccessibilityLabel result count: %lu",
|
|
393
|
+
(unsigned long)result.count);
|
|
394
|
+
return result;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
#pragma mark - Screenshot API
|
|
398
|
+
|
|
399
|
+
- (void)captureScreenshot:(JS::NativeHarnessUI::ViewInfo *)bounds
|
|
400
|
+
resolve:(RCTPromiseResolveBlock)resolve
|
|
401
|
+
reject:(RCTPromiseRejectBlock)reject {
|
|
402
|
+
RCTLogInfo(@"[HarnessUI] captureScreenshot called");
|
|
403
|
+
|
|
404
|
+
CGRect captureRect = CGRectNull;
|
|
405
|
+
|
|
406
|
+
if (bounds) {
|
|
407
|
+
double width = bounds->width();
|
|
408
|
+
double height = bounds->height();
|
|
409
|
+
|
|
410
|
+
if (width > 0 && height > 0) {
|
|
411
|
+
captureRect = CGRectMake(bounds->x(), bounds->y(), width, height);
|
|
412
|
+
RCTLogInfo(@"[HarnessUI] Capturing region: x=%.2f y=%.2f w=%.2f h=%.2f",
|
|
413
|
+
captureRect.origin.x, captureRect.origin.y,
|
|
414
|
+
captureRect.size.width, captureRect.size.height);
|
|
415
|
+
}
|
|
416
|
+
} else {
|
|
417
|
+
RCTLogInfo(@"[HarnessUI] Capturing full window");
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
dispatch_async(dispatch_get_main_queue(), ^{
|
|
421
|
+
NSData *pngData = [ViewQueryHelper captureScreenshotWithBounds:captureRect];
|
|
422
|
+
|
|
423
|
+
if (pngData) {
|
|
424
|
+
// Return Base64 string for efficiency
|
|
425
|
+
NSString *base64String = [pngData base64EncodedStringWithOptions:0];
|
|
426
|
+
RCTLogInfo(@"[HarnessUI] Screenshot captured successfully (%lu bytes)",
|
|
427
|
+
(unsigned long)pngData.length);
|
|
428
|
+
resolve(base64String);
|
|
429
|
+
} else {
|
|
430
|
+
RCTLogInfo(@"[HarnessUI] Screenshot capture returned nil");
|
|
431
|
+
resolve([NSNull null]);
|
|
432
|
+
}
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
#pragma mark - Text Input
|
|
437
|
+
|
|
438
|
+
- (UIResponder *)findFirstResponderInView:(UIView *)view {
|
|
439
|
+
if ([view isFirstResponder]) {
|
|
440
|
+
return view;
|
|
441
|
+
}
|
|
442
|
+
for (UIView *subview in view.subviews) {
|
|
443
|
+
UIResponder *responder = [self findFirstResponderInView:subview];
|
|
444
|
+
if (responder) {
|
|
445
|
+
return responder;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
return nil;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
- (void)typeChar:(NSString *)character
|
|
452
|
+
resolve:(RCTPromiseResolveBlock)resolve
|
|
453
|
+
reject:(RCTPromiseRejectBlock)reject {
|
|
454
|
+
RCTLogInfo(@"[HarnessUI] typeChar called with: %@", character);
|
|
455
|
+
|
|
456
|
+
dispatch_async(dispatch_get_main_queue(), ^{
|
|
457
|
+
UIWindow *window = [self getActiveWindow];
|
|
458
|
+
UIResponder *firstResponder = [self findFirstResponderInView:window];
|
|
459
|
+
|
|
460
|
+
if ([firstResponder conformsToProtocol:@protocol(UITextInput)]) {
|
|
461
|
+
id<UITextInput> textInput = (id<UITextInput>)firstResponder;
|
|
462
|
+
[textInput insertText:character];
|
|
463
|
+
RCTLogInfo(@"[HarnessUI] Inserted character: %@", character);
|
|
464
|
+
} else {
|
|
465
|
+
RCTLogInfo(@"[HarnessUI] No text input is focused");
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// Small delay for React Native to process the change event
|
|
469
|
+
dispatch_after(dispatch_time(DISPATCH_TIME_NOW,
|
|
470
|
+
(int64_t)(kEventProcessingDelay * NSEC_PER_SEC)),
|
|
471
|
+
dispatch_get_main_queue(), ^{
|
|
472
|
+
resolve(nil);
|
|
473
|
+
});
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
- (void)blur:(JS::NativeHarnessUI::SpecBlurOptions &)options
|
|
478
|
+
resolve:(RCTPromiseResolveBlock)resolve
|
|
479
|
+
reject:(RCTPromiseRejectBlock)reject {
|
|
480
|
+
RCTLogInfo(@"[HarnessUI] blur called");
|
|
481
|
+
|
|
482
|
+
// Extract value before async dispatch to avoid dangling reference
|
|
483
|
+
BOOL shouldSubmitEditing = options.submitEditing().value_or(false);
|
|
484
|
+
|
|
485
|
+
dispatch_async(dispatch_get_main_queue(), ^{
|
|
486
|
+
UIWindow *window = [self getActiveWindow];
|
|
487
|
+
UIResponder *firstResponder = [self findFirstResponderInView:window];
|
|
488
|
+
|
|
489
|
+
if (firstResponder) {
|
|
490
|
+
if (shouldSubmitEditing) {
|
|
491
|
+
RCTLogInfo(@"[HarnessUI] Triggering submitEditing");
|
|
492
|
+
// Trigger submitEditing event if the responder supports it
|
|
493
|
+
// For UITextField, this simulates pressing Return
|
|
494
|
+
if ([firstResponder isKindOfClass:[UITextField class]]) {
|
|
495
|
+
UITextField *textField = (UITextField *)firstResponder;
|
|
496
|
+
[textField.delegate textFieldShouldReturn:textField];
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// Resign first responder (triggers endEditing and blur events)
|
|
501
|
+
RCTLogInfo(@"[HarnessUI] Resigning first responder");
|
|
502
|
+
[firstResponder resignFirstResponder];
|
|
503
|
+
} else {
|
|
504
|
+
RCTLogInfo(@"[HarnessUI] No first responder found");
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
dispatch_after(dispatch_time(DISPATCH_TIME_NOW,
|
|
508
|
+
(int64_t)(kEventProcessingDelay * NSEC_PER_SEC)),
|
|
509
|
+
dispatch_get_main_queue(), ^{
|
|
510
|
+
resolve(nil);
|
|
511
|
+
});
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
#pragma mark - TurboModule plumbing
|
|
516
|
+
|
|
517
|
+
- (std::shared_ptr<facebook::react::TurboModule>)getTurboModule:
|
|
518
|
+
(const facebook::react::ObjCTurboModule::InitParams &)params {
|
|
519
|
+
return std::make_shared<facebook::react::NativeHarnessUISpecJSI>(params);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
+ (NSString *)moduleName {
|
|
523
|
+
return @"HarnessUI";
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
@end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
#import <UIKit/UIKit.h>
|
|
2
|
+
|
|
3
|
+
NS_ASSUME_NONNULL_BEGIN
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Enum defining the types of queries that can be performed on the view hierarchy.
|
|
7
|
+
* Add new query types here to extend functionality.
|
|
8
|
+
*/
|
|
9
|
+
typedef NS_ENUM(NSInteger, ViewQueryType) {
|
|
10
|
+
ViewQueryTypeTestId, // Matches accessibilityIdentifier
|
|
11
|
+
ViewQueryTypeAccessibilityLabel // Matches accessibilityLabel
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Represents information about a found view.
|
|
16
|
+
*/
|
|
17
|
+
@interface ViewQueryResult : NSObject
|
|
18
|
+
|
|
19
|
+
@property (nonatomic, assign) CGFloat x;
|
|
20
|
+
@property (nonatomic, assign) CGFloat y;
|
|
21
|
+
@property (nonatomic, assign) CGFloat width;
|
|
22
|
+
@property (nonatomic, assign) CGFloat height;
|
|
23
|
+
|
|
24
|
+
- (NSDictionary *)toDictionary;
|
|
25
|
+
|
|
26
|
+
@end
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Helper class for querying the view hierarchy.
|
|
30
|
+
* Provides reusable query logic for finding views by various criteria.
|
|
31
|
+
*/
|
|
32
|
+
@interface ViewQueryHelper : NSObject
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Returns the currently active window.
|
|
36
|
+
*/
|
|
37
|
+
+ (nullable UIWindow *)getActiveWindow;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Finds the first view matching the query criteria.
|
|
41
|
+
* @param queryType The type of query to perform.
|
|
42
|
+
* @param value The value to match against.
|
|
43
|
+
* @return ViewQueryResult if found, nil otherwise.
|
|
44
|
+
*/
|
|
45
|
+
+ (nullable ViewQueryResult *)queryWithType:(ViewQueryType)queryType value:(NSString *)value;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Finds all views matching the query criteria.
|
|
49
|
+
* @param queryType The type of query to perform.
|
|
50
|
+
* @param value The value to match against.
|
|
51
|
+
* @return Array of ViewQueryResult objects.
|
|
52
|
+
*/
|
|
53
|
+
+ (NSArray<ViewQueryResult *> *)queryAllWithType:(ViewQueryType)queryType value:(NSString *)value;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Captures a screenshot of the window or a specific region.
|
|
57
|
+
* @param bounds Optional CGRect for capturing a specific region. Pass CGRectNull for full window.
|
|
58
|
+
* @return NSData containing PNG image data, or nil on failure.
|
|
59
|
+
*/
|
|
60
|
+
+ (nullable NSData *)captureScreenshotWithBounds:(CGRect)bounds;
|
|
61
|
+
|
|
62
|
+
@end
|
|
63
|
+
|
|
64
|
+
NS_ASSUME_NONNULL_END
|