@react-native-harness/ui 1.0.0-alpha.23 → 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/HarnessUI.d.ts +17 -0
- package/dist/HarnessUI.d.ts.map +1 -0
- package/dist/HarnessUI.js +2 -0
- package/dist/HarnessUI.web.d.ts +14 -0
- package/dist/HarnessUI.web.d.ts.map +1 -0
- package/dist/HarnessUI.web.js +46 -0
- package/dist/NativeHarness.d.ts +22 -0
- package/dist/NativeHarness.d.ts.map +1 -0
- package/dist/NativeHarness.js +2 -0
- package/dist/NativeHarnessUI.d.ts +10 -37
- package/dist/NativeHarnessUI.d.ts.map +1 -1
- package/dist/WebHarness.d.ts +14 -0
- package/dist/WebHarness.d.ts.map +1 -0
- package/dist/WebHarness.js +53 -0
- package/dist/WebHarnessUI.d.ts +15 -0
- package/dist/WebHarnessUI.d.ts.map +1 -0
- package/dist/WebHarnessUI.js +81 -0
- package/dist/harness/NativeHarness.d.ts +17 -0
- package/dist/harness/NativeHarness.d.ts.map +1 -0
- package/dist/harness/NativeHarness.js +2 -0
- package/dist/harness/WebHarness.d.ts +14 -0
- package/dist/harness/WebHarness.d.ts.map +1 -0
- package/dist/harness/WebHarness.js +53 -0
- package/dist/harness/index.d.ts +4 -0
- package/dist/harness/index.d.ts.map +1 -0
- package/dist/harness/index.js +8 -0
- package/dist/harness/native-harness.d.ts +17 -0
- package/dist/harness/native-harness.d.ts.map +1 -0
- package/dist/harness/native-harness.js +2 -0
- package/dist/harness/web-harness.d.ts +14 -0
- package/dist/harness/web-harness.d.ts.map +1 -0
- package/dist/harness/web-harness.js +53 -0
- package/dist/harness.d.ts +4 -0
- package/dist/harness.d.ts.map +1 -0
- package/dist/harness.js +8 -0
- 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 +50 -20
- package/dist/tsconfig.lib.tsbuildinfo +1 -1
- package/dist/types.d.ts +48 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +1 -0
- package/dist/userEvent.d.ts.map +1 -1
- package/dist/userEvent.js +10 -19
- 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 +11 -45
- package/src/WebHarnessUI.ts +113 -0
- package/src/harness.ts +12 -0
- package/src/index.ts +1 -1
- package/src/screen.ts +64 -27
- package/src/types.ts +54 -0
- package/src/userEvent.ts +10 -19
package/dist/userEvent.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import
|
|
1
|
+
import HarnessUI from './harness.js';
|
|
2
2
|
/**
|
|
3
3
|
* Flushes pending events on the JS event loop.
|
|
4
4
|
* This ensures that any events triggered by native code (like onPress callbacks)
|
|
@@ -23,45 +23,36 @@ const flushEvents = () => {
|
|
|
23
23
|
const createUserEvent = () => {
|
|
24
24
|
return {
|
|
25
25
|
press: async (element) => {
|
|
26
|
-
//
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
await NativeHarnessUI.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
|
|
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 NativeHarnessUI.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 NativeHarnessUI.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
|
|
58
49
|
for (const char of text) {
|
|
59
|
-
await
|
|
50
|
+
await HarnessUI.typeChar(char);
|
|
60
51
|
await flushEvents(); // Let onChangeText fire
|
|
61
52
|
}
|
|
62
53
|
// Blur (triggers endEditing and blur unless skipBlur)
|
|
63
54
|
if (!options?.skipBlur) {
|
|
64
|
-
await
|
|
55
|
+
await HarnessUI.blur({
|
|
65
56
|
submitEditing: options?.submitEditing ?? false,
|
|
66
57
|
});
|
|
67
58
|
await flushEvents();
|
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,66 +1,32 @@
|
|
|
1
1
|
import { TurboModuleRegistry, type TurboModule } from 'react-native';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Represents
|
|
4
|
+
* Represents a bounding box in screen coordinates (points/dp).
|
|
5
5
|
*/
|
|
6
|
-
export interface
|
|
6
|
+
export interface BoundingBox {
|
|
7
7
|
x: number;
|
|
8
8
|
y: number;
|
|
9
9
|
width: number;
|
|
10
10
|
height: number;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
+
}
|
|
19
20
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
* Returns null if no view is found.
|
|
23
|
-
*/
|
|
21
|
+
interface Spec extends TurboModule {
|
|
22
|
+
simulatePress(nativeId: string, x: number, y: number): Promise<void>;
|
|
24
23
|
queryByTestId(testId: string): ViewInfo | null;
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* Finds all views by testID (accessibilityIdentifier on iOS, tag on Android).
|
|
28
|
-
* Returns an empty array if no views are found.
|
|
29
|
-
*/
|
|
30
24
|
queryAllByTestId(testId: string): ViewInfo[];
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* Finds a view by its accessibility label.
|
|
34
|
-
* Returns null if no view is found.
|
|
35
|
-
*/
|
|
36
25
|
queryByAccessibilityLabel(label: string): ViewInfo | null;
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* Finds all views by accessibility label.
|
|
40
|
-
* Returns an empty array if no views are found.
|
|
41
|
-
*/
|
|
42
26
|
queryAllByAccessibilityLabel(label: string): ViewInfo[];
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* Captures a screenshot of the app UI.
|
|
46
|
-
* @param bounds Optional bounds to capture a specific region. Pass null to capture the entire window.
|
|
47
|
-
* @returns Promise resolving to Base64 encoded string containing PNG data, or null on failure.
|
|
48
|
-
*/
|
|
49
27
|
captureScreenshot(bounds: ViewInfo | null): Promise<string | null>;
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* Types a single character into the currently focused text input.
|
|
53
|
-
* If no text input is focused, this is a no-op.
|
|
54
|
-
*/
|
|
55
28
|
typeChar(character: string): Promise<void>;
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* Blurs (resigns first responder from) the currently focused element.
|
|
59
|
-
* Optionally triggers submitEditing event before blur.
|
|
60
|
-
*/
|
|
61
29
|
blur(options: { submitEditing?: boolean }): Promise<void>;
|
|
62
30
|
}
|
|
63
31
|
|
|
64
|
-
export type HarnessUIModule = Spec;
|
|
65
|
-
|
|
66
32
|
export default TurboModuleRegistry.getEnforcing<Spec>('HarnessUI');
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { type HarnessUIModule, type ViewInfo } from './types.js';
|
|
2
|
+
|
|
3
|
+
declare global {
|
|
4
|
+
interface Window {
|
|
5
|
+
__RN_HARNESS_CAPTURE_SCREENSHOT__: (
|
|
6
|
+
bounds: ViewInfo | null
|
|
7
|
+
) => Promise<string | null>;
|
|
8
|
+
__RN_HARNESS_SIMULATE_PRESS__: (x: number, y: number) => Promise<void>;
|
|
9
|
+
__RN_HARNESS_TYPE_CHAR__: (character: string) => Promise<void>;
|
|
10
|
+
__RN_HARNESS_BLUR__: (options: {
|
|
11
|
+
submitEditing?: boolean;
|
|
12
|
+
}) => Promise<void>;
|
|
13
|
+
__RN_HARNESS_VIEW_REGISTRY__: Map<string, Element>;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
if (!window.__RN_HARNESS_VIEW_REGISTRY__) {
|
|
18
|
+
window.__RN_HARNESS_VIEW_REGISTRY__ = new Map();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
let nextId = 1;
|
|
22
|
+
|
|
23
|
+
const getElementViewInfo = (element: Element): ViewInfo => {
|
|
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
|
+
|
|
33
|
+
return {
|
|
34
|
+
x: rect.left,
|
|
35
|
+
y: rect.top,
|
|
36
|
+
width: rect.width,
|
|
37
|
+
height: rect.height,
|
|
38
|
+
nativeId,
|
|
39
|
+
};
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const WebHarnessUI: HarnessUIModule = {
|
|
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);
|
|
57
|
+
},
|
|
58
|
+
|
|
59
|
+
queryByTestId: (testId) => {
|
|
60
|
+
const element = document.querySelector(`[data-testid="${testId}"]`);
|
|
61
|
+
return element ? getElementViewInfo(element) : null;
|
|
62
|
+
},
|
|
63
|
+
|
|
64
|
+
queryAllByTestId: (testId) => {
|
|
65
|
+
const elements = document.querySelectorAll(`[data-testid="${testId}"]`);
|
|
66
|
+
return Array.from(elements).map(getElementViewInfo);
|
|
67
|
+
},
|
|
68
|
+
|
|
69
|
+
queryByAccessibilityLabel: (label) => {
|
|
70
|
+
const element = document.querySelector(`[aria-label="${label}"]`);
|
|
71
|
+
return element ? getElementViewInfo(element) : null;
|
|
72
|
+
},
|
|
73
|
+
|
|
74
|
+
queryAllByAccessibilityLabel: (label) => {
|
|
75
|
+
const elements = document.querySelectorAll(`[aria-label="${label}"]`);
|
|
76
|
+
return Array.from(elements).map(getElementViewInfo);
|
|
77
|
+
},
|
|
78
|
+
|
|
79
|
+
captureScreenshot: async (bounds) => {
|
|
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);
|
|
88
|
+
},
|
|
89
|
+
|
|
90
|
+
typeChar: async (character) => {
|
|
91
|
+
await window.__RN_HARNESS_TYPE_CHAR__(character);
|
|
92
|
+
},
|
|
93
|
+
|
|
94
|
+
blur: async (options) => {
|
|
95
|
+
if (options.submitEditing) {
|
|
96
|
+
// If we want to submit, we must NOT blur before pressing Enter.
|
|
97
|
+
// We let the runner-side bridge handle both Enter and the subsequent blur.
|
|
98
|
+
await window.__RN_HARNESS_BLUR__(options);
|
|
99
|
+
} else {
|
|
100
|
+
// If there is a focused element, blur it directly in the DOM first
|
|
101
|
+
// to trigger local events, then call the runner-side bridge.
|
|
102
|
+
if (
|
|
103
|
+
document.activeElement instanceof HTMLElement ||
|
|
104
|
+
document.activeElement instanceof SVGElement
|
|
105
|
+
) {
|
|
106
|
+
document.activeElement.blur();
|
|
107
|
+
}
|
|
108
|
+
await window.__RN_HARNESS_BLUR__(options);
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
export default WebHarnessUI;
|
package/src/harness.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { Platform } from "react-native";
|
|
2
|
+
import { HarnessUIModule } from "./types.js";
|
|
3
|
+
|
|
4
|
+
const getHarnessUI = (): HarnessUIModule => {
|
|
5
|
+
if (Platform.OS === 'web') {
|
|
6
|
+
return require('./WebHarnessUI.js').default;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
return require('./NativeHarnessUI.js').default;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export default getHarnessUI();
|
package/src/index.ts
CHANGED