@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/dist/types.d.ts
CHANGED
|
@@ -1,18 +1,11 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
*/
|
|
4
|
-
export interface ViewInfo {
|
|
5
|
-
x: number;
|
|
6
|
-
y: number;
|
|
7
|
-
width: number;
|
|
8
|
-
height: number;
|
|
9
|
-
}
|
|
1
|
+
import type { ViewInfo } from "./NativeHarnessUI.js";
|
|
2
|
+
export type { ViewInfo, BoundingBox } from "./NativeHarnessUI.js";
|
|
10
3
|
export interface HarnessUIModule {
|
|
11
4
|
/**
|
|
12
|
-
* Simulates a native press at
|
|
5
|
+
* Simulates a native press on a view identified by nativeId, or at coordinates if nativeId is empty.
|
|
13
6
|
* Returns a promise that resolves when the press action is complete.
|
|
14
7
|
*/
|
|
15
|
-
simulatePress(x: number, y: number): Promise<void>;
|
|
8
|
+
simulatePress(nativeId: string, x: number, y: number): Promise<void>;
|
|
16
9
|
/**
|
|
17
10
|
* Finds a view by its testID (accessibilityIdentifier on iOS, tag on Android).
|
|
18
11
|
* Returns null if no view is found.
|
package/dist/types.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,sBAAsB,CAAC;AAErD,YAAY,EAAE,QAAQ,EAAE,WAAW,EAAE,MAAM,sBAAsB,CAAC;AAElE,MAAM,WAAW,eAAe;IAC9B;;;OAGG;IACH,aAAa,CAAC,QAAQ,EAAE,MAAM,EAAE,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAErE;;;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"}
|
package/dist/userEvent.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"userEvent.d.ts","sourceRoot":"","sources":["../src/userEvent.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAC;AAwBpD,MAAM,MAAM,SAAS,GAAG;IACtB;;;;OAIG;IACH,KAAK,EAAE,CAAC,OAAO,EAAE,gBAAgB,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAEpD;;;OAGG;IACH,OAAO,EAAE,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAEjD;;;;;;;;;;;OAWG;IACH,IAAI,EAAE,CACJ,OAAO,EAAE,gBAAgB,EACzB,IAAI,EAAE,MAAM,EACZ,OAAO,CAAC,EAAE;QACR,SAAS,CAAC,EAAE,OAAO,CAAC;QACpB,QAAQ,CAAC,EAAE,OAAO,CAAC;QACnB,aAAa,CAAC,EAAE,OAAO,CAAC;KACzB,KACE,OAAO,CAAC,IAAI,CAAC,CAAC;CACpB,CAAC;
|
|
1
|
+
{"version":3,"file":"userEvent.d.ts","sourceRoot":"","sources":["../src/userEvent.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAC;AAwBpD,MAAM,MAAM,SAAS,GAAG;IACtB;;;;OAIG;IACH,KAAK,EAAE,CAAC,OAAO,EAAE,gBAAgB,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAEpD;;;OAGG;IACH,OAAO,EAAE,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAEjD;;;;;;;;;;;OAWG;IACH,IAAI,EAAE,CACJ,OAAO,EAAE,gBAAgB,EACzB,IAAI,EAAE,MAAM,EACZ,OAAO,CAAC,EAAE;QACR,SAAS,CAAC,EAAE,OAAO,CAAC;QACpB,QAAQ,CAAC,EAAE,OAAO,CAAC;QACnB,aAAa,CAAC,EAAE,OAAO,CAAC;KACzB,KACE,OAAO,CAAC,IAAI,CAAC,CAAC;CACpB,CAAC;AAsDF,eAAO,MAAM,SAAS,WAAoB,CAAC"}
|
package/dist/userEvent.js
CHANGED
|
@@ -23,35 +23,26 @@ const flushEvents = () => {
|
|
|
23
23
|
const createUserEvent = () => {
|
|
24
24
|
return {
|
|
25
25
|
press: async (element) => {
|
|
26
|
-
//
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
await HarnessUI.simulatePress(centerX, centerY);
|
|
26
|
+
// We pass 0, 0 as coordinates when nativeId is provided.
|
|
27
|
+
// Native side will resolve the view and calculate the center.
|
|
28
|
+
await HarnessUI.simulatePress(element.nativeId, 0, 0);
|
|
30
29
|
// Flush pending events to ensure onPress and other callbacks are processed
|
|
31
30
|
await flushEvents();
|
|
32
31
|
},
|
|
33
32
|
pressAt: async (x, y) => {
|
|
34
|
-
await HarnessUI.simulatePress(x, y);
|
|
33
|
+
await HarnessUI.simulatePress('', x, y);
|
|
35
34
|
// Flush pending events to ensure onPress and other callbacks are processed
|
|
36
35
|
await flushEvents();
|
|
37
36
|
},
|
|
38
37
|
type: async (element, text, options) => {
|
|
39
38
|
// Press to focus the element (triggers pressIn/pressOut unless skipPress is true)
|
|
40
|
-
// Note: Currently we always press to focus, the skipPress option would need
|
|
41
|
-
// additional implementation in simulatePress to avoid firing press events
|
|
42
39
|
if (!options?.skipPress) {
|
|
43
|
-
|
|
44
|
-
const centerX = element.x + element.width / 2;
|
|
45
|
-
const centerY = element.y + element.height / 2;
|
|
46
|
-
await HarnessUI.simulatePress(centerX, centerY);
|
|
40
|
+
await HarnessUI.simulatePress(element.nativeId, 0, 0);
|
|
47
41
|
await flushEvents();
|
|
48
42
|
}
|
|
49
43
|
else {
|
|
50
|
-
// Still need to press to focus
|
|
51
|
-
|
|
52
|
-
const centerX = element.x + element.width / 2;
|
|
53
|
-
const centerY = element.y + element.height / 2;
|
|
54
|
-
await HarnessUI.simulatePress(centerX, centerY);
|
|
44
|
+
// Still need to press to focus
|
|
45
|
+
await HarnessUI.simulatePress(element.nativeId, 0, 0);
|
|
55
46
|
await flushEvents();
|
|
56
47
|
}
|
|
57
48
|
// Type each character one by one
|
package/ios/HarnessUI.mm
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
#import "HarnessUI.h"
|
|
2
2
|
#import "ViewQueryHelper.h"
|
|
3
|
+
#import "ViewRegistry.h"
|
|
3
4
|
#import <QuartzCore/QuartzCore.h>
|
|
4
5
|
#import <React/RCTBridgeModule.h>
|
|
5
6
|
#import <React/RCTLog.h>
|
|
@@ -231,17 +232,34 @@ static NSTimeInterval _lastTapTime = 0;
|
|
|
231
232
|
[self executeTapAtPoint:point retryCount:0 completion:completion];
|
|
232
233
|
}
|
|
233
234
|
|
|
234
|
-
- (void)simulatePress:(
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
235
|
+
- (void)simulatePress:(NSString *)nativeId
|
|
236
|
+
x:(double)x
|
|
237
|
+
y:(double)y
|
|
238
|
+
resolve:(RCTPromiseResolveBlock)resolve
|
|
239
|
+
reject:(RCTPromiseRejectBlock)reject {
|
|
240
|
+
RCTLogInfo(@"[HarnessUI] simulatePress called with nativeId:%@ x:%.2f y:%.2f", nativeId, x, y);
|
|
239
241
|
|
|
240
242
|
dispatch_async(dispatch_get_main_queue(), ^{
|
|
241
243
|
NSTimeInterval now = CACurrentMediaTime();
|
|
242
244
|
NSTimeInterval timeSinceLastTap = now - _lastTapTime;
|
|
243
245
|
CGPoint point = CGPointMake(x, y);
|
|
244
246
|
|
|
247
|
+
if (nativeId && nativeId.length > 0) {
|
|
248
|
+
UIView *view = [ViewRegistry getView:nativeId];
|
|
249
|
+
if (view) {
|
|
250
|
+
UIWindow *window = [self getActiveWindow]; // Or view.window? view.window is safer if attached.
|
|
251
|
+
if (!window) window = view.window;
|
|
252
|
+
|
|
253
|
+
if (window) {
|
|
254
|
+
CGRect frameInWindow = [view convertRect:view.bounds toView:window];
|
|
255
|
+
point = CGPointMake(CGRectGetMidX(frameInWindow), CGRectGetMidY(frameInWindow));
|
|
256
|
+
RCTLogInfo(@"[HarnessUI] Resolved view %@ to point (%.2f, %.2f)", nativeId, point.x, point.y);
|
|
257
|
+
}
|
|
258
|
+
} else {
|
|
259
|
+
RCTLogWarn(@"[HarnessUI] View with nativeId %@ not found or collected. Using provided coordinates.", nativeId);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
245
263
|
// Completion block that actually resolves the promise
|
|
246
264
|
void (^completionBlock)(void) = ^{
|
|
247
265
|
// Check if tap was successful by verifying we found a window/view
|
|
@@ -402,23 +420,55 @@ static NSTimeInterval _lastTapTime = 0;
|
|
|
402
420
|
RCTLogInfo(@"[HarnessUI] captureScreenshot called");
|
|
403
421
|
|
|
404
422
|
CGRect captureRect = CGRectNull;
|
|
423
|
+
NSString *nativeId = nil;
|
|
405
424
|
|
|
406
425
|
if (bounds) {
|
|
426
|
+
if (bounds->nativeId().length > 0) {
|
|
427
|
+
nativeId = bounds->nativeId();
|
|
428
|
+
}
|
|
429
|
+
|
|
407
430
|
double width = bounds->width();
|
|
408
431
|
double height = bounds->height();
|
|
409
432
|
|
|
410
433
|
if (width > 0 && height > 0) {
|
|
411
434
|
captureRect = CGRectMake(bounds->x(), bounds->y(), width, height);
|
|
412
|
-
RCTLogInfo(@"[HarnessUI] Capturing region: x=%.2f y=%.2f w=%.2f h=%.2f",
|
|
435
|
+
RCTLogInfo(@"[HarnessUI] Capturing region: x=%.2f y=%.2f w=%.2f h=%.2f nativeId=%@",
|
|
413
436
|
captureRect.origin.x, captureRect.origin.y,
|
|
414
|
-
captureRect.size.width, captureRect.size.height);
|
|
437
|
+
captureRect.size.width, captureRect.size.height, nativeId);
|
|
415
438
|
}
|
|
416
439
|
} else {
|
|
417
440
|
RCTLogInfo(@"[HarnessUI] Capturing full window");
|
|
418
441
|
}
|
|
419
442
|
|
|
420
443
|
dispatch_async(dispatch_get_main_queue(), ^{
|
|
421
|
-
NSData *pngData =
|
|
444
|
+
NSData *pngData = nil;
|
|
445
|
+
|
|
446
|
+
if (nativeId) {
|
|
447
|
+
UIView *targetView = [ViewRegistry getView:nativeId];
|
|
448
|
+
if (targetView) {
|
|
449
|
+
UIWindow *window = [self getActiveWindow];
|
|
450
|
+
if (window) {
|
|
451
|
+
CGRect frameInWindow = [targetView convertRect:targetView.bounds toView:window];
|
|
452
|
+
BOOL isFullyOnScreen = CGRectContainsRect(window.bounds, frameInWindow);
|
|
453
|
+
|
|
454
|
+
if (isFullyOnScreen) {
|
|
455
|
+
RCTLogInfo(@"[HarnessUI] View %@ is fully on screen. Using screen-based screenshotting.", nativeId);
|
|
456
|
+
pngData = [ViewQueryHelper captureScreenshotWithBounds:frameInWindow];
|
|
457
|
+
} else {
|
|
458
|
+
RCTLogInfo(@"[HarnessUI] View %@ is partially/fully off screen. Using direct view render.", nativeId);
|
|
459
|
+
pngData = [ViewQueryHelper captureScreenshotOfView:targetView];
|
|
460
|
+
}
|
|
461
|
+
} else {
|
|
462
|
+
RCTLogWarn(@"[HarnessUI] No active window found for nativeId: %@. Using direct view render.", nativeId);
|
|
463
|
+
pngData = [ViewQueryHelper captureScreenshotOfView:targetView];
|
|
464
|
+
}
|
|
465
|
+
} else {
|
|
466
|
+
RCTLogWarn(@"[HarnessUI] View with nativeId %@ not found. Falling back to window bounds capture.", nativeId);
|
|
467
|
+
pngData = [ViewQueryHelper captureScreenshotWithBounds:captureRect];
|
|
468
|
+
}
|
|
469
|
+
} else {
|
|
470
|
+
pngData = [ViewQueryHelper captureScreenshotWithBounds:captureRect];
|
|
471
|
+
}
|
|
422
472
|
|
|
423
473
|
if (pngData) {
|
|
424
474
|
// Return Base64 string for efficiency
|
package/ios/ViewQueryHelper.h
CHANGED
|
@@ -20,6 +20,7 @@ typedef NS_ENUM(NSInteger, ViewQueryType) {
|
|
|
20
20
|
@property (nonatomic, assign) CGFloat y;
|
|
21
21
|
@property (nonatomic, assign) CGFloat width;
|
|
22
22
|
@property (nonatomic, assign) CGFloat height;
|
|
23
|
+
@property (nonatomic, copy) NSString *nativeId;
|
|
23
24
|
|
|
24
25
|
- (NSDictionary *)toDictionary;
|
|
25
26
|
|
|
@@ -59,6 +60,14 @@ typedef NS_ENUM(NSInteger, ViewQueryType) {
|
|
|
59
60
|
*/
|
|
60
61
|
+ (nullable NSData *)captureScreenshotWithBounds:(CGRect)bounds;
|
|
61
62
|
|
|
63
|
+
/**
|
|
64
|
+
* Captures a screenshot of a specific view, rendering its layer directly.
|
|
65
|
+
* This allows capturing content that might be off-screen or larger than the window.
|
|
66
|
+
* @param view The view to capture.
|
|
67
|
+
* @return NSData containing PNG image data, or nil on failure.
|
|
68
|
+
*/
|
|
69
|
+
+ (nullable NSData *)captureScreenshotOfView:(UIView *)view;
|
|
70
|
+
|
|
62
71
|
@end
|
|
63
72
|
|
|
64
73
|
NS_ASSUME_NONNULL_END
|
package/ios/ViewQueryHelper.mm
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
#import "ViewQueryHelper.h"
|
|
2
|
+
#import "ViewRegistry.h"
|
|
2
3
|
|
|
3
4
|
// =============================================================================
|
|
4
5
|
// ViewQueryResult Implementation
|
|
@@ -11,7 +12,8 @@
|
|
|
11
12
|
@"x": @(self.x),
|
|
12
13
|
@"y": @(self.y),
|
|
13
14
|
@"width": @(self.width),
|
|
14
|
-
@"height": @(self.height)
|
|
15
|
+
@"height": @(self.height),
|
|
16
|
+
@"nativeId": self.nativeId ?: @""
|
|
15
17
|
};
|
|
16
18
|
}
|
|
17
19
|
|
|
@@ -119,6 +121,7 @@
|
|
|
119
121
|
result.y = frameInWindow.origin.y;
|
|
120
122
|
result.width = frameInWindow.size.width;
|
|
121
123
|
result.height = frameInWindow.size.height;
|
|
124
|
+
result.nativeId = [ViewRegistry registerView:view];
|
|
122
125
|
return result;
|
|
123
126
|
}
|
|
124
127
|
|
|
@@ -184,4 +187,24 @@
|
|
|
184
187
|
return UIImagePNGRepresentation(image);
|
|
185
188
|
}
|
|
186
189
|
|
|
190
|
+
+ (nullable NSData *)captureScreenshotOfView:(UIView *)view {
|
|
191
|
+
if (!view) {
|
|
192
|
+
return nil;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Use UIGraphicsImageRenderer to render the view's layer
|
|
196
|
+
UIGraphicsImageRendererFormat *format = [[UIGraphicsImageRendererFormat alloc] init];
|
|
197
|
+
format.scale = [UIScreen mainScreen].scale;
|
|
198
|
+
format.opaque = NO; // Allow transparency if the view has it
|
|
199
|
+
|
|
200
|
+
UIGraphicsImageRenderer *renderer = [[UIGraphicsImageRenderer alloc] initWithSize:view.bounds.size format:format];
|
|
201
|
+
|
|
202
|
+
UIImage *image = [renderer imageWithActions:^(UIGraphicsImageRendererContext *context) {
|
|
203
|
+
// Render the view's layer directly into the context
|
|
204
|
+
[view.layer renderInContext:context.CGContext];
|
|
205
|
+
}];
|
|
206
|
+
|
|
207
|
+
return UIImagePNGRepresentation(image);
|
|
208
|
+
}
|
|
209
|
+
|
|
187
210
|
@end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
#import "ViewRegistry.h"
|
|
2
|
+
|
|
3
|
+
@implementation ViewRegistry
|
|
4
|
+
|
|
5
|
+
+ (NSMapTable<NSString *, UIView *> *)registry {
|
|
6
|
+
static NSMapTable *registry = nil;
|
|
7
|
+
static dispatch_once_t onceToken;
|
|
8
|
+
dispatch_once(&onceToken, ^{
|
|
9
|
+
// Key: Strong (NSString), Value: Weak (UIView)
|
|
10
|
+
// When UIView is deallocated, it is automatically removed from the map.
|
|
11
|
+
registry = [NSMapTable strongToWeakObjectsMapTable];
|
|
12
|
+
});
|
|
13
|
+
return registry;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
+ (NSString *)registerView:(UIView *)view {
|
|
17
|
+
if (!view) return nil;
|
|
18
|
+
|
|
19
|
+
// Generate a unique ID. Using a random UUID ensures freshness for each query.
|
|
20
|
+
NSString *nativeId = [[NSUUID UUID] UUIDString];
|
|
21
|
+
|
|
22
|
+
// Store in registry
|
|
23
|
+
[[self registry] setObject:view forKey:nativeId];
|
|
24
|
+
|
|
25
|
+
return nativeId;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
+ (UIView *)getView:(NSString *)nativeId {
|
|
29
|
+
if (!nativeId) return nil;
|
|
30
|
+
return [[self registry] objectForKey:nativeId];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
@end
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@react-native-harness/ui",
|
|
3
3
|
"description": "Native UI testing module for React Native Harness.",
|
|
4
|
-
"version": "1.0.0-alpha.
|
|
4
|
+
"version": "1.0.0-alpha.25",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
7
7
|
"module": "./dist/index.js",
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
40
|
"tslib": "^2.3.0",
|
|
41
|
-
"@react-native-harness/runtime": "1.0.0-alpha.
|
|
41
|
+
"@react-native-harness/runtime": "1.0.0-alpha.25"
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
44
|
"react-native": "*"
|
package/src/NativeHarnessUI.ts
CHANGED
|
@@ -1,15 +1,25 @@
|
|
|
1
1
|
import { TurboModuleRegistry, type TurboModule } from 'react-native';
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
/**
|
|
4
|
+
* Represents a bounding box in screen coordinates (points/dp).
|
|
5
|
+
*/
|
|
6
|
+
export interface BoundingBox {
|
|
5
7
|
x: number;
|
|
6
8
|
y: number;
|
|
7
9
|
width: number;
|
|
8
10
|
height: number;
|
|
9
11
|
}
|
|
10
12
|
|
|
13
|
+
/**
|
|
14
|
+
* Internal interface used for bridge communication.
|
|
15
|
+
* This needs to be exported for TurboModule codegen.
|
|
16
|
+
*/
|
|
17
|
+
export interface ViewInfo extends BoundingBox {
|
|
18
|
+
nativeId: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
11
21
|
interface Spec extends TurboModule {
|
|
12
|
-
simulatePress(x: number, y: number): Promise<void>;
|
|
22
|
+
simulatePress(nativeId: string, x: number, y: number): Promise<void>;
|
|
13
23
|
queryByTestId(testId: string): ViewInfo | null;
|
|
14
24
|
queryAllByTestId(testId: string): ViewInfo[];
|
|
15
25
|
queryByAccessibilityLabel(label: string): ViewInfo | null;
|
package/src/WebHarnessUI.ts
CHANGED
|
@@ -10,22 +10,50 @@ declare global {
|
|
|
10
10
|
__RN_HARNESS_BLUR__: (options: {
|
|
11
11
|
submitEditing?: boolean;
|
|
12
12
|
}) => Promise<void>;
|
|
13
|
+
__RN_HARNESS_VIEW_REGISTRY__: Map<string, Element>;
|
|
13
14
|
}
|
|
14
15
|
}
|
|
15
16
|
|
|
17
|
+
if (!window.__RN_HARNESS_VIEW_REGISTRY__) {
|
|
18
|
+
window.__RN_HARNESS_VIEW_REGISTRY__ = new Map();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
let nextId = 1;
|
|
22
|
+
|
|
16
23
|
const getElementViewInfo = (element: Element): ViewInfo => {
|
|
17
24
|
const rect = element.getBoundingClientRect();
|
|
25
|
+
|
|
26
|
+
let nativeId = (element as any).__harnessId;
|
|
27
|
+
if (!nativeId) {
|
|
28
|
+
nativeId = `view_${nextId++}`;
|
|
29
|
+
(element as any).__harnessId = nativeId;
|
|
30
|
+
window.__RN_HARNESS_VIEW_REGISTRY__.set(nativeId, element);
|
|
31
|
+
}
|
|
32
|
+
|
|
18
33
|
return {
|
|
19
34
|
x: rect.left,
|
|
20
35
|
y: rect.top,
|
|
21
36
|
width: rect.width,
|
|
22
37
|
height: rect.height,
|
|
38
|
+
nativeId,
|
|
23
39
|
};
|
|
24
40
|
};
|
|
25
41
|
|
|
26
42
|
const WebHarnessUI: HarnessUIModule = {
|
|
27
|
-
simulatePress: async (x, y) => {
|
|
28
|
-
|
|
43
|
+
simulatePress: async (nativeId, x, y) => {
|
|
44
|
+
let targetX = x;
|
|
45
|
+
let targetY = y;
|
|
46
|
+
|
|
47
|
+
if (nativeId) {
|
|
48
|
+
const element = window.__RN_HARNESS_VIEW_REGISTRY__.get(nativeId);
|
|
49
|
+
if (element) {
|
|
50
|
+
const rect = element.getBoundingClientRect();
|
|
51
|
+
targetX = rect.left + rect.width / 2;
|
|
52
|
+
targetY = rect.top + rect.height / 2;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
await window.__RN_HARNESS_SIMULATE_PRESS__(targetX, targetY);
|
|
29
57
|
},
|
|
30
58
|
|
|
31
59
|
queryByTestId: (testId) => {
|
|
@@ -49,7 +77,14 @@ const WebHarnessUI: HarnessUIModule = {
|
|
|
49
77
|
},
|
|
50
78
|
|
|
51
79
|
captureScreenshot: async (bounds) => {
|
|
52
|
-
|
|
80
|
+
let captureBounds = bounds;
|
|
81
|
+
if (bounds?.nativeId && bounds.width === 0 && bounds.height === 0) {
|
|
82
|
+
const element = window.__RN_HARNESS_VIEW_REGISTRY__.get(bounds.nativeId);
|
|
83
|
+
if (element) {
|
|
84
|
+
captureBounds = getElementViewInfo(element);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return await window.__RN_HARNESS_CAPTURE_SCREENSHOT__(captureBounds);
|
|
53
88
|
},
|
|
54
89
|
|
|
55
90
|
typeChar: async (character) => {
|
package/src/index.ts
CHANGED
package/src/screen.ts
CHANGED
|
@@ -1,12 +1,18 @@
|
|
|
1
|
-
import { type ViewInfo } from './types.js';
|
|
1
|
+
import { type ViewInfo, type BoundingBox } from './types.js';
|
|
2
2
|
import { waitFor } from '@react-native-harness/runtime';
|
|
3
3
|
import HarnessUI from './harness.js';
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
|
-
* Represents an element found on screen
|
|
7
|
-
* This can be used with userEvent
|
|
6
|
+
* Represents an element found on screen.
|
|
7
|
+
* This is an opaque reference that can be used with userEvent or screenshot.
|
|
8
8
|
*/
|
|
9
|
-
export type ElementReference =
|
|
9
|
+
export type ElementReference = {
|
|
10
|
+
readonly nativeId: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const wrapElement = (viewInfo: ViewInfo): ElementReference => ({
|
|
14
|
+
nativeId: viewInfo.nativeId,
|
|
15
|
+
});
|
|
10
16
|
|
|
11
17
|
/**
|
|
12
18
|
* Screenshot result containing PNG image data.
|
|
@@ -70,11 +76,13 @@ export type Screen = {
|
|
|
70
76
|
queryAllByAccessibilityLabel: (label: string) => ElementReference[];
|
|
71
77
|
|
|
72
78
|
/**
|
|
73
|
-
* Captures a screenshot of the entire app window
|
|
74
|
-
* @param
|
|
79
|
+
* Captures a screenshot of the entire app window, a specific element, or a region.
|
|
80
|
+
* @param target Optional element reference or bounding box to capture. If not provided, captures the entire window.
|
|
75
81
|
* @returns Promise resolving to ScreenshotResult with PNG data, or null if capture fails.
|
|
76
82
|
*/
|
|
77
|
-
screenshot: (
|
|
83
|
+
screenshot: (
|
|
84
|
+
target?: ElementReference | BoundingBox
|
|
85
|
+
) => Promise<ScreenshotResult | null>;
|
|
78
86
|
};
|
|
79
87
|
|
|
80
88
|
const createScreen = (): Screen => {
|
|
@@ -85,7 +93,7 @@ const createScreen = (): Screen => {
|
|
|
85
93
|
if (!result) {
|
|
86
94
|
throw new Error(`Unable to find element with testID: ${testId}`);
|
|
87
95
|
}
|
|
88
|
-
return result;
|
|
96
|
+
return wrapElement(result);
|
|
89
97
|
});
|
|
90
98
|
},
|
|
91
99
|
|
|
@@ -95,16 +103,17 @@ const createScreen = (): Screen => {
|
|
|
95
103
|
if (results.length === 0) {
|
|
96
104
|
throw new Error(`Unable to find any elements with testID: ${testId}`);
|
|
97
105
|
}
|
|
98
|
-
return results;
|
|
106
|
+
return results.map(wrapElement);
|
|
99
107
|
});
|
|
100
108
|
},
|
|
101
109
|
|
|
102
110
|
queryByTestId: (testId: string): ElementReference | null => {
|
|
103
|
-
|
|
111
|
+
const result = HarnessUI.queryByTestId(testId);
|
|
112
|
+
return result ? wrapElement(result) : null;
|
|
104
113
|
},
|
|
105
114
|
|
|
106
115
|
queryAllByTestId: (testId: string): ElementReference[] => {
|
|
107
|
-
return HarnessUI.queryAllByTestId(testId);
|
|
116
|
+
return HarnessUI.queryAllByTestId(testId).map(wrapElement);
|
|
108
117
|
},
|
|
109
118
|
|
|
110
119
|
findByAccessibilityLabel: async (
|
|
@@ -117,7 +126,7 @@ const createScreen = (): Screen => {
|
|
|
117
126
|
`Unable to find element with accessibility label: ${label}`
|
|
118
127
|
);
|
|
119
128
|
}
|
|
120
|
-
return result;
|
|
129
|
+
return wrapElement(result);
|
|
121
130
|
});
|
|
122
131
|
},
|
|
123
132
|
|
|
@@ -131,31 +140,53 @@ const createScreen = (): Screen => {
|
|
|
131
140
|
`Unable to find any elements with accessibility label: ${label}`
|
|
132
141
|
);
|
|
133
142
|
}
|
|
134
|
-
return results;
|
|
143
|
+
return results.map(wrapElement);
|
|
135
144
|
});
|
|
136
145
|
},
|
|
137
146
|
|
|
138
147
|
queryByAccessibilityLabel: (label: string): ElementReference | null => {
|
|
139
|
-
|
|
148
|
+
const result = HarnessUI.queryByAccessibilityLabel(label);
|
|
149
|
+
return result ? wrapElement(result) : null;
|
|
140
150
|
},
|
|
141
151
|
|
|
142
152
|
queryAllByAccessibilityLabel: (label: string): ElementReference[] => {
|
|
143
|
-
return HarnessUI.queryAllByAccessibilityLabel(label);
|
|
153
|
+
return HarnessUI.queryAllByAccessibilityLabel(label).map(wrapElement);
|
|
144
154
|
},
|
|
145
155
|
|
|
146
156
|
screenshot: async (
|
|
147
|
-
|
|
157
|
+
target?: ElementReference | BoundingBox
|
|
148
158
|
): Promise<ScreenshotResult | null> => {
|
|
149
|
-
|
|
150
|
-
|
|
159
|
+
let captureBounds: ViewInfo | null = null;
|
|
160
|
+
let targetWidth = 0;
|
|
161
|
+
let targetHeight = 0;
|
|
162
|
+
|
|
163
|
+
if (target) {
|
|
164
|
+
if ('nativeId' in target) {
|
|
165
|
+
// ElementReference
|
|
166
|
+
captureBounds = {
|
|
167
|
+
nativeId: target.nativeId,
|
|
168
|
+
x: 0,
|
|
169
|
+
y: 0,
|
|
170
|
+
width: 0,
|
|
171
|
+
height: 0,
|
|
172
|
+
};
|
|
173
|
+
} else {
|
|
174
|
+
// BoundingBox
|
|
175
|
+
captureBounds = {
|
|
176
|
+
nativeId: '',
|
|
177
|
+
...target,
|
|
178
|
+
};
|
|
179
|
+
targetWidth = target.width;
|
|
180
|
+
targetHeight = target.height;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const base64String = await HarnessUI.captureScreenshot(captureBounds);
|
|
151
185
|
|
|
152
186
|
if (!base64String) {
|
|
153
187
|
return null;
|
|
154
188
|
}
|
|
155
189
|
|
|
156
|
-
const width = element?.width ?? 0;
|
|
157
|
-
const height = element?.height ?? 0;
|
|
158
|
-
|
|
159
190
|
// Decode Base64 string to Uint8Array
|
|
160
191
|
const binaryString = atob(base64String);
|
|
161
192
|
const len = binaryString.length;
|
|
@@ -164,10 +195,15 @@ const createScreen = (): Screen => {
|
|
|
164
195
|
bytes[i] = binaryString.charCodeAt(i);
|
|
165
196
|
}
|
|
166
197
|
|
|
198
|
+
// If we captured by nativeId, we might not know the width/height beforehand in JS.
|
|
199
|
+
// But the native side returns the actual captured PNG.
|
|
200
|
+
// Ideally we'd get the size from the native side, but currently the bridge doesn't return it.
|
|
201
|
+
// For now we use the provided target size or 0.
|
|
202
|
+
|
|
167
203
|
return {
|
|
168
204
|
data: bytes,
|
|
169
|
-
width,
|
|
170
|
-
height,
|
|
205
|
+
width: targetWidth,
|
|
206
|
+
height: targetHeight,
|
|
171
207
|
};
|
|
172
208
|
},
|
|
173
209
|
};
|
package/src/types.ts
CHANGED
|
@@ -1,19 +1,13 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
export interface ViewInfo {
|
|
5
|
-
x: number;
|
|
6
|
-
y: number;
|
|
7
|
-
width: number;
|
|
8
|
-
height: number;
|
|
9
|
-
}
|
|
1
|
+
import type { ViewInfo } from "./NativeHarnessUI.js";
|
|
2
|
+
|
|
3
|
+
export type { ViewInfo, BoundingBox } from "./NativeHarnessUI.js";
|
|
10
4
|
|
|
11
5
|
export interface HarnessUIModule {
|
|
12
6
|
/**
|
|
13
|
-
* Simulates a native press at
|
|
7
|
+
* Simulates a native press on a view identified by nativeId, or at coordinates if nativeId is empty.
|
|
14
8
|
* Returns a promise that resolves when the press action is complete.
|
|
15
9
|
*/
|
|
16
|
-
simulatePress(x: number, y: number): Promise<void>;
|
|
10
|
+
simulatePress(nativeId: string, x: number, y: number): Promise<void>;
|
|
17
11
|
|
|
18
12
|
/**
|
|
19
13
|
* Finds a view by its testID (accessibilityIdentifier on iOS, tag on Android).
|
package/src/userEvent.ts
CHANGED
|
@@ -63,16 +63,15 @@ export type UserEvent = {
|
|
|
63
63
|
const createUserEvent = (): UserEvent => {
|
|
64
64
|
return {
|
|
65
65
|
press: async (element: ElementReference): Promise<void> => {
|
|
66
|
-
//
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
await HarnessUI.simulatePress(centerX, centerY);
|
|
66
|
+
// We pass 0, 0 as coordinates when nativeId is provided.
|
|
67
|
+
// Native side will resolve the view and calculate the center.
|
|
68
|
+
await HarnessUI.simulatePress(element.nativeId, 0, 0);
|
|
70
69
|
// Flush pending events to ensure onPress and other callbacks are processed
|
|
71
70
|
await flushEvents();
|
|
72
71
|
},
|
|
73
72
|
|
|
74
73
|
pressAt: async (x: number, y: number): Promise<void> => {
|
|
75
|
-
await HarnessUI.simulatePress(x, y);
|
|
74
|
+
await HarnessUI.simulatePress('', x, y);
|
|
76
75
|
// Flush pending events to ensure onPress and other callbacks are processed
|
|
77
76
|
await flushEvents();
|
|
78
77
|
},
|
|
@@ -87,20 +86,12 @@ const createUserEvent = (): UserEvent => {
|
|
|
87
86
|
}
|
|
88
87
|
): Promise<void> => {
|
|
89
88
|
// Press to focus the element (triggers pressIn/pressOut unless skipPress is true)
|
|
90
|
-
// Note: Currently we always press to focus, the skipPress option would need
|
|
91
|
-
// additional implementation in simulatePress to avoid firing press events
|
|
92
89
|
if (!options?.skipPress) {
|
|
93
|
-
|
|
94
|
-
const centerX = element.x + element.width / 2;
|
|
95
|
-
const centerY = element.y + element.height / 2;
|
|
96
|
-
await HarnessUI.simulatePress(centerX, centerY);
|
|
90
|
+
await HarnessUI.simulatePress(element.nativeId, 0, 0);
|
|
97
91
|
await flushEvents();
|
|
98
92
|
} else {
|
|
99
|
-
// Still need to press to focus
|
|
100
|
-
|
|
101
|
-
const centerX = element.x + element.width / 2;
|
|
102
|
-
const centerY = element.y + element.height / 2;
|
|
103
|
-
await HarnessUI.simulatePress(centerX, centerY);
|
|
93
|
+
// Still need to press to focus
|
|
94
|
+
await HarnessUI.simulatePress(element.nativeId, 0, 0);
|
|
104
95
|
await flushEvents();
|
|
105
96
|
}
|
|
106
97
|
|