@react-native-harness/ui 1.0.0-alpha.24 → 1.0.0

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/dist/types.d.ts CHANGED
@@ -1,18 +1,11 @@
1
- /**
2
- * Represents the position and dimensions of a view in screen coordinates (points/dp).
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 the specified screen coordinates.
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.
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;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,MAAM,WAAW,eAAe;IAC9B;;;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"}
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"}
@@ -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;AA+DF,eAAO,MAAM,SAAS,WAAoB,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
- // Calculate center point of the element
27
- const centerX = element.x + element.width / 2;
28
- const centerY = element.y + element.height / 2;
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
- // Calculate center point of the element
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, but ideally without press events
51
- // For now, we press anyway - future enhancement could add a focusOnly method
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:(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);
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 = [ViewQueryHelper captureScreenshotWithBounds:captureRect];
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
@@ -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
@@ -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,8 @@
1
+ #import <UIKit/UIKit.h>
2
+
3
+ @interface ViewRegistry : NSObject
4
+
5
+ + (NSString *)registerView:(UIView *)view;
6
+ + (UIView *)getView:(NSString *)nativeId;
7
+
8
+ @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.24",
4
+ "version": "1.0.0",
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.24"
41
+ "@react-native-harness/runtime": "1.0.0"
42
42
  },
43
43
  "devDependencies": {
44
44
  "react-native": "*"
@@ -1,15 +1,25 @@
1
1
  import { TurboModuleRegistry, type TurboModule } from 'react-native';
2
2
 
3
- // This interface needs to be there for Codegen to work.
4
- export interface ViewInfo {
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;
@@ -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
- await window.__RN_HARNESS_SIMULATE_PRESS__(x, y);
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
- return await window.__RN_HARNESS_CAPTURE_SCREENSHOT__(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);
53
88
  },
54
89
 
55
90
  typeChar: async (character) => {
package/src/index.ts CHANGED
@@ -15,4 +15,4 @@ export {
15
15
  type ScreenshotResult,
16
16
  } from './screen.js';
17
17
  export { userEvent, type UserEvent } from './userEvent.js';
18
- export type { ViewInfo } from './types.js';
18
+ export type { ViewInfo, BoundingBox } from './types.js';
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 with its position and dimensions.
7
- * This can be used with userEvent.press() to interact with the element.
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 = ViewInfo;
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 or a specific element.
74
- * @param element Optional element reference to capture. If not provided, captures the entire window.
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: (element?: ElementReference) => Promise<ScreenshotResult | null>;
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
- return HarnessUI.queryByTestId(testId);
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
- return HarnessUI.queryByAccessibilityLabel(label);
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
- element?: ElementReference
157
+ target?: ElementReference | BoundingBox
148
158
  ): Promise<ScreenshotResult | null> => {
149
- const bounds = element ?? null;
150
- const base64String = await HarnessUI.captureScreenshot(bounds);
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
- * Represents the position and dimensions of a view in screen coordinates (points/dp).
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 the specified screen coordinates.
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
- // Calculate center point of the element
67
- const centerX = element.x + element.width / 2;
68
- const centerY = element.y + element.height / 2;
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
- // Calculate center point of the element
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, but ideally without press events
100
- // For now, we press anyway - future enhancement could add a focusOnly method
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