@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.
@@ -0,0 +1,187 @@
1
+ #import "ViewQueryHelper.h"
2
+
3
+ // =============================================================================
4
+ // ViewQueryResult Implementation
5
+ // =============================================================================
6
+
7
+ @implementation ViewQueryResult
8
+
9
+ - (NSDictionary *)toDictionary {
10
+ return @{
11
+ @"x": @(self.x),
12
+ @"y": @(self.y),
13
+ @"width": @(self.width),
14
+ @"height": @(self.height)
15
+ };
16
+ }
17
+
18
+ @end
19
+
20
+ // =============================================================================
21
+ // ViewQueryHelper Implementation
22
+ // =============================================================================
23
+
24
+ @implementation ViewQueryHelper
25
+
26
+ #pragma mark - Window Access
27
+
28
+ + (nullable UIWindow *)getActiveWindow {
29
+ UIWindow *window = nil;
30
+ if (@available(iOS 13.0, *)) {
31
+ for (UIWindowScene *scene in [UIApplication sharedApplication].connectedScenes) {
32
+ if (scene.activationState == UISceneActivationStateForegroundActive) {
33
+ for (UIWindow *w in scene.windows) {
34
+ if (w.isKeyWindow) {
35
+ window = w;
36
+ break;
37
+ }
38
+ }
39
+ if (!window) {
40
+ window = scene.windows.firstObject;
41
+ }
42
+ break;
43
+ }
44
+ }
45
+ } else {
46
+ window = [UIApplication sharedApplication].keyWindow;
47
+ }
48
+ return window;
49
+ }
50
+
51
+ #pragma mark - View Matching
52
+
53
+ /**
54
+ * Checks if a view matches the given query criteria.
55
+ * Add new query type matching logic here.
56
+ */
57
+ + (BOOL)view:(UIView *)view matchesQueryType:(ViewQueryType)queryType value:(NSString *)value {
58
+ switch (queryType) {
59
+ case ViewQueryTypeTestId:
60
+ return [view.accessibilityIdentifier isEqualToString:value];
61
+
62
+ case ViewQueryTypeAccessibilityLabel:
63
+ return [view.accessibilityLabel isEqualToString:value];
64
+
65
+ // Add new query types here:
66
+ // case ViewQueryTypeNewType:
67
+ // return [view.someProperty isEqualToString:value];
68
+ }
69
+ return NO;
70
+ }
71
+
72
+ #pragma mark - View Traversal
73
+
74
+ /**
75
+ * Recursively finds the first view matching the query criteria.
76
+ */
77
+ + (nullable UIView *)findViewInView:(UIView *)view
78
+ queryType:(ViewQueryType)queryType
79
+ value:(NSString *)value {
80
+ if ([self view:view matchesQueryType:queryType value:value]) {
81
+ return view;
82
+ }
83
+
84
+ for (UIView *subview in view.subviews) {
85
+ UIView *found = [self findViewInView:subview queryType:queryType value:value];
86
+ if (found) {
87
+ return found;
88
+ }
89
+ }
90
+ return nil;
91
+ }
92
+
93
+ /**
94
+ * Recursively finds all views matching the query criteria.
95
+ */
96
+ + (void)findAllViewsInView:(UIView *)view
97
+ queryType:(ViewQueryType)queryType
98
+ value:(NSString *)value
99
+ results:(NSMutableArray<UIView *> *)results {
100
+ if ([self view:view matchesQueryType:queryType value:value]) {
101
+ [results addObject:view];
102
+ }
103
+
104
+ for (UIView *subview in view.subviews) {
105
+ [self findAllViewsInView:subview queryType:queryType value:value results:results];
106
+ }
107
+ }
108
+
109
+ #pragma mark - Result Conversion
110
+
111
+ /**
112
+ * Converts a UIView to a ViewQueryResult with screen coordinates.
113
+ */
114
+ + (ViewQueryResult *)resultFromView:(UIView *)view window:(UIWindow *)window {
115
+ CGRect frameInWindow = [view convertRect:view.bounds toView:window];
116
+
117
+ ViewQueryResult *result = [[ViewQueryResult alloc] init];
118
+ result.x = frameInWindow.origin.x;
119
+ result.y = frameInWindow.origin.y;
120
+ result.width = frameInWindow.size.width;
121
+ result.height = frameInWindow.size.height;
122
+ return result;
123
+ }
124
+
125
+ #pragma mark - Public Query API
126
+
127
+ + (nullable ViewQueryResult *)queryWithType:(ViewQueryType)queryType value:(NSString *)value {
128
+ UIWindow *window = [self getActiveWindow];
129
+ if (!window) {
130
+ return nil;
131
+ }
132
+
133
+ UIView *found = [self findViewInView:window queryType:queryType value:value];
134
+ if (!found) {
135
+ return nil;
136
+ }
137
+
138
+ return [self resultFromView:found window:window];
139
+ }
140
+
141
+ + (NSArray<ViewQueryResult *> *)queryAllWithType:(ViewQueryType)queryType value:(NSString *)value {
142
+ UIWindow *window = [self getActiveWindow];
143
+ if (!window) {
144
+ return @[];
145
+ }
146
+
147
+ NSMutableArray<UIView *> *views = [NSMutableArray array];
148
+ [self findAllViewsInView:window queryType:queryType value:value results:views];
149
+
150
+ NSMutableArray<ViewQueryResult *> *results = [NSMutableArray arrayWithCapacity:views.count];
151
+ for (UIView *view in views) {
152
+ [results addObject:[self resultFromView:view window:window]];
153
+ }
154
+ return results;
155
+ }
156
+
157
+ #pragma mark - Screenshot Capture
158
+
159
+ + (nullable NSData *)captureScreenshotWithBounds:(CGRect)bounds {
160
+ UIWindow *window = [self getActiveWindow];
161
+ if (!window) {
162
+ return nil;
163
+ }
164
+
165
+ // Determine capture rect
166
+ CGRect captureRect = CGRectIsNull(bounds) ? window.bounds : bounds;
167
+
168
+ // Use UIGraphicsImageRenderer for modern, efficient rendering
169
+ UIGraphicsImageRendererFormat *format = [[UIGraphicsImageRendererFormat alloc] init];
170
+ format.scale = [UIScreen mainScreen].scale;
171
+ format.opaque = YES;
172
+
173
+ UIGraphicsImageRenderer *renderer = [[UIGraphicsImageRenderer alloc] initWithSize:captureRect.size format:format];
174
+
175
+ UIImage *image = [renderer imageWithActions:^(UIGraphicsImageRendererContext *context) {
176
+ // Translate to capture the correct region
177
+ CGContextTranslateCTM(context.CGContext, -captureRect.origin.x, -captureRect.origin.y);
178
+
179
+ // Draw the window hierarchy
180
+ [window drawViewHierarchyInRect:window.bounds afterScreenUpdates:YES];
181
+ }];
182
+
183
+ // Convert to PNG
184
+ return UIImagePNGRepresentation(image);
185
+ }
186
+
187
+ @end
package/package.json ADDED
@@ -0,0 +1,64 @@
1
+ {
2
+ "name": "@react-native-harness/ui",
3
+ "description": "Native UI testing module for React Native Harness.",
4
+ "version": "1.0.0-alpha.22",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "module": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "files": [
10
+ "src",
11
+ "dist",
12
+ "android",
13
+ "ios",
14
+ "*.podspec",
15
+ "react-native.config.js",
16
+ "!ios/build",
17
+ "!android/build",
18
+ "!android/gradle",
19
+ "!android/gradlew",
20
+ "!android/gradlew.bat",
21
+ "!android/local.properties",
22
+ "!**/__tests__",
23
+ "!**/__fixtures__",
24
+ "!**/__mocks__",
25
+ "!**/.*"
26
+ ],
27
+ "exports": {
28
+ "./package.json": "./package.json",
29
+ ".": {
30
+ "development": "./src/index.ts",
31
+ "types": "./dist/index.d.ts",
32
+ "import": "./dist/index.js",
33
+ "default": "./dist/index.js"
34
+ }
35
+ },
36
+ "peerDependencies": {
37
+ "react-native": "*"
38
+ },
39
+ "dependencies": {
40
+ "tslib": "^2.3.0",
41
+ "@react-native-harness/runtime": "1.0.0-alpha.22"
42
+ },
43
+ "devDependencies": {
44
+ "react-native": "*"
45
+ },
46
+ "codegenConfig": {
47
+ "name": "HarnessUISpec",
48
+ "type": "modules",
49
+ "jsSrcsDir": "src",
50
+ "android": {
51
+ "javaPackageName": "com.harnessui"
52
+ }
53
+ },
54
+ "author": {
55
+ "name": "Szymon Chmal",
56
+ "email": "szymon.chmal@callstack.com"
57
+ },
58
+ "homepage": "https://github.com/callstackincubator/react-native-harness",
59
+ "repository": {
60
+ "type": "git",
61
+ "url": "https://github.com/callstackincubator/react-native-harness.git"
62
+ },
63
+ "license": "MIT"
64
+ }
@@ -0,0 +1,66 @@
1
+ import { TurboModuleRegistry, type TurboModule } from 'react-native';
2
+
3
+ /**
4
+ * Represents the position and dimensions of a view in screen coordinates (points/dp).
5
+ */
6
+ export interface ViewInfo {
7
+ x: number;
8
+ y: number;
9
+ width: number;
10
+ height: number;
11
+ }
12
+
13
+ interface Spec extends TurboModule {
14
+ /**
15
+ * Simulates a native press at the specified screen coordinates.
16
+ * Returns a promise that resolves when the press action is complete.
17
+ */
18
+ simulatePress(x: number, y: number): Promise<void>;
19
+
20
+ /**
21
+ * Finds a view by its testID (accessibilityIdentifier on iOS, tag on Android).
22
+ * Returns null if no view is found.
23
+ */
24
+ 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
+ queryAllByTestId(testId: string): ViewInfo[];
31
+
32
+ /**
33
+ * Finds a view by its accessibility label.
34
+ * Returns null if no view is found.
35
+ */
36
+ 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
+ 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
+ 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
+ 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
+ blur(options: { submitEditing?: boolean }): Promise<void>;
62
+ }
63
+
64
+ export type HarnessUIModule = Spec;
65
+
66
+ export default TurboModuleRegistry.getEnforcing<Spec>('HarnessUI');
package/src/index.ts ADDED
@@ -0,0 +1,18 @@
1
+ /**
2
+ * @react-native-harness/ui
3
+ *
4
+ * Native UI testing module for React Native Harness.
5
+ * Provides view queries and touch simulation.
6
+ *
7
+ * This module is automatically excluded from release builds
8
+ * and only available in debug builds.
9
+ */
10
+
11
+ export {
12
+ screen,
13
+ type Screen,
14
+ type ElementReference,
15
+ type ScreenshotResult,
16
+ } from './screen.js';
17
+ export { userEvent, type UserEvent } from './userEvent.js';
18
+ export type { ViewInfo } from './NativeHarnessUI.js';
package/src/screen.ts ADDED
@@ -0,0 +1,175 @@
1
+ import NativeHarnessUI, { type ViewInfo } from './NativeHarnessUI.js';
2
+ import { waitFor } from '@react-native-harness/runtime';
3
+
4
+ /**
5
+ * Represents an element found on screen with its position and dimensions.
6
+ * This can be used with userEvent.press() to interact with the element.
7
+ */
8
+ export type ElementReference = ViewInfo;
9
+
10
+ /**
11
+ * Screenshot result containing PNG image data.
12
+ */
13
+ export interface ScreenshotResult {
14
+ /** PNG image data as Uint8Array (ArrayBuffer view) */
15
+ data: Uint8Array;
16
+ /** Width of the captured image in logical pixels (points/dp) */
17
+ width: number;
18
+ /** Height of the captured image in logical pixels (points/dp) */
19
+ height: number;
20
+ }
21
+
22
+ export type Screen = {
23
+ /**
24
+ * Finds an element by its testID (accessibilityIdentifier on iOS, tag on Android).
25
+ * @throws Error if no element is found with the given testID.
26
+ */
27
+ findByTestId: (testId: string) => Promise<ElementReference>;
28
+
29
+ /**
30
+ * Finds all elements by testID (accessibilityIdentifier on iOS, tag on Android).
31
+ * @throws Error if no elements are found with the given testID.
32
+ */
33
+ findAllByTestId: (testId: string) => Promise<ElementReference[]>;
34
+
35
+ /**
36
+ * Queries for an element by its testID without throwing.
37
+ * Returns null if no element is found.
38
+ */
39
+ queryByTestId: (testId: string) => ElementReference | null;
40
+
41
+ /**
42
+ * Queries for all elements by testID without throwing.
43
+ * Returns an empty array if no elements are found.
44
+ */
45
+ queryAllByTestId: (testId: string) => ElementReference[];
46
+
47
+ /**
48
+ * Finds an element by its accessibility label.
49
+ * @throws Error if no element is found with the given label.
50
+ */
51
+ findByAccessibilityLabel: (label: string) => Promise<ElementReference>;
52
+
53
+ /**
54
+ * Finds all elements by accessibility label.
55
+ * @throws Error if no elements are found with the given label.
56
+ */
57
+ findAllByAccessibilityLabel: (label: string) => Promise<ElementReference[]>;
58
+
59
+ /**
60
+ * Queries for an element by its accessibility label without throwing.
61
+ * Returns null if no element is found.
62
+ */
63
+ queryByAccessibilityLabel: (label: string) => ElementReference | null;
64
+
65
+ /**
66
+ * Queries for all elements by accessibility label without throwing.
67
+ * Returns an empty array if no elements are found.
68
+ */
69
+ queryAllByAccessibilityLabel: (label: string) => ElementReference[];
70
+
71
+ /**
72
+ * Captures a screenshot of the entire app window or a specific element.
73
+ * @param element Optional element reference to capture. If not provided, captures the entire window.
74
+ * @returns Promise resolving to ScreenshotResult with PNG data, or null if capture fails.
75
+ */
76
+ screenshot: (element?: ElementReference) => Promise<ScreenshotResult | null>;
77
+ };
78
+
79
+ const createScreen = (): Screen => {
80
+ return {
81
+ findByTestId: async (testId: string): Promise<ElementReference> => {
82
+ return waitFor(() => {
83
+ const result = NativeHarnessUI.queryByTestId(testId);
84
+ if (!result) {
85
+ throw new Error(`Unable to find element with testID: ${testId}`);
86
+ }
87
+ return result;
88
+ });
89
+ },
90
+
91
+ findAllByTestId: async (testId: string): Promise<ElementReference[]> => {
92
+ return waitFor(() => {
93
+ const results = NativeHarnessUI.queryAllByTestId(testId);
94
+ if (results.length === 0) {
95
+ throw new Error(`Unable to find any elements with testID: ${testId}`);
96
+ }
97
+ return results;
98
+ });
99
+ },
100
+
101
+ queryByTestId: (testId: string): ElementReference | null => {
102
+ return NativeHarnessUI.queryByTestId(testId);
103
+ },
104
+
105
+ queryAllByTestId: (testId: string): ElementReference[] => {
106
+ return NativeHarnessUI.queryAllByTestId(testId);
107
+ },
108
+
109
+ findByAccessibilityLabel: async (
110
+ label: string
111
+ ): Promise<ElementReference> => {
112
+ return waitFor(() => {
113
+ const result = NativeHarnessUI.queryByAccessibilityLabel(label);
114
+ if (!result) {
115
+ throw new Error(
116
+ `Unable to find element with accessibility label: ${label}`
117
+ );
118
+ }
119
+ return result;
120
+ });
121
+ },
122
+
123
+ findAllByAccessibilityLabel: async (
124
+ label: string
125
+ ): Promise<ElementReference[]> => {
126
+ return waitFor(() => {
127
+ const results = NativeHarnessUI.queryAllByAccessibilityLabel(label);
128
+ if (results.length === 0) {
129
+ throw new Error(
130
+ `Unable to find any elements with accessibility label: ${label}`
131
+ );
132
+ }
133
+ return results;
134
+ });
135
+ },
136
+
137
+ queryByAccessibilityLabel: (label: string): ElementReference | null => {
138
+ return NativeHarnessUI.queryByAccessibilityLabel(label);
139
+ },
140
+
141
+ queryAllByAccessibilityLabel: (label: string): ElementReference[] => {
142
+ return NativeHarnessUI.queryAllByAccessibilityLabel(label);
143
+ },
144
+
145
+ screenshot: async (
146
+ element?: ElementReference
147
+ ): Promise<ScreenshotResult | null> => {
148
+ const bounds = element ?? null;
149
+ const base64String = await NativeHarnessUI.captureScreenshot(bounds);
150
+
151
+ if (!base64String) {
152
+ return null;
153
+ }
154
+
155
+ const width = element?.width ?? 0;
156
+ const height = element?.height ?? 0;
157
+
158
+ // Decode Base64 string to Uint8Array
159
+ const binaryString = atob(base64String);
160
+ const len = binaryString.length;
161
+ const bytes = new Uint8Array(len);
162
+ for (let i = 0; i < len; i++) {
163
+ bytes[i] = binaryString.charCodeAt(i);
164
+ }
165
+
166
+ return {
167
+ data: bytes,
168
+ width,
169
+ height,
170
+ };
171
+ },
172
+ };
173
+ };
174
+
175
+ export const screen = createScreen();
@@ -0,0 +1,124 @@
1
+ import NativeHarnessUI from './NativeHarnessUI.js';
2
+ import type { ElementReference } from './screen.js';
3
+
4
+ /**
5
+ * Flushes pending events on the JS event loop.
6
+ * This ensures that any events triggered by native code (like onPress callbacks)
7
+ * are processed before we continue.
8
+ *
9
+ * Uses requestAnimationFrame to wait for the next frame render, ensuring React
10
+ * has had time to process events and commit state updates. Includes a small
11
+ * timeout buffer for slow devices.
12
+ */
13
+ const flushEvents = (): Promise<void> => {
14
+ return new Promise((resolve) => {
15
+ // Wait for the next frame - ensures React has processed events and rendered
16
+ requestAnimationFrame(() => {
17
+ // Wait for another frame to handle any cascading updates
18
+ requestAnimationFrame(() => {
19
+ // Small timeout buffer for slow devices to complete any async processing
20
+ setTimeout(resolve, 16);
21
+ });
22
+ });
23
+ });
24
+ };
25
+
26
+ export type UserEvent = {
27
+ /**
28
+ * Simulates a press on the given element.
29
+ * The press occurs at the center of the element's bounds.
30
+ * Returns a promise that resolves when the press is complete.
31
+ */
32
+ press: (element: ElementReference) => Promise<void>;
33
+
34
+ /**
35
+ * Simulates a press at the specified screen coordinates.
36
+ * Returns a promise that resolves when the press is complete.
37
+ */
38
+ pressAt: (x: number, y: number) => Promise<void>;
39
+
40
+ /**
41
+ * Simulates typing text into a text input element.
42
+ * This helper focuses the element by tapping it, types text one character at a time,
43
+ * and then blurs the element (unless skipBlur is true).
44
+ *
45
+ * @param element - The element to type into (should be a TextInput)
46
+ * @param text - The text to type
47
+ * @param options - Optional configuration
48
+ * @param options.skipPress - If true, pressIn and pressOut events will not be triggered
49
+ * @param options.skipBlur - If true, endEditing and blur events will not be triggered when typing is complete
50
+ * @param options.submitEditing - If true, submitEditing event will be triggered after typing the text
51
+ */
52
+ type: (
53
+ element: ElementReference,
54
+ text: string,
55
+ options?: {
56
+ skipPress?: boolean;
57
+ skipBlur?: boolean;
58
+ submitEditing?: boolean;
59
+ }
60
+ ) => Promise<void>;
61
+ };
62
+
63
+ const createUserEvent = (): UserEvent => {
64
+ return {
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 NativeHarnessUI.simulatePress(centerX, centerY);
70
+ // Flush pending events to ensure onPress and other callbacks are processed
71
+ await flushEvents();
72
+ },
73
+
74
+ pressAt: async (x: number, y: number): Promise<void> => {
75
+ await NativeHarnessUI.simulatePress(x, y);
76
+ // Flush pending events to ensure onPress and other callbacks are processed
77
+ await flushEvents();
78
+ },
79
+
80
+ type: async (
81
+ element: ElementReference,
82
+ text: string,
83
+ options?: {
84
+ skipPress?: boolean;
85
+ skipBlur?: boolean;
86
+ submitEditing?: boolean;
87
+ }
88
+ ): Promise<void> => {
89
+ // 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
+ 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 NativeHarnessUI.simulatePress(centerX, centerY);
97
+ await flushEvents();
98
+ } 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 NativeHarnessUI.simulatePress(centerX, centerY);
104
+ await flushEvents();
105
+ }
106
+
107
+ // Type each character one by one
108
+ for (const char of text) {
109
+ await NativeHarnessUI.typeChar(char);
110
+ await flushEvents(); // Let onChangeText fire
111
+ }
112
+
113
+ // Blur (triggers endEditing and blur unless skipBlur)
114
+ if (!options?.skipBlur) {
115
+ await NativeHarnessUI.blur({
116
+ submitEditing: options?.submitEditing ?? false,
117
+ });
118
+ await flushEvents();
119
+ }
120
+ },
121
+ };
122
+ };
123
+
124
+ export const userEvent = createUserEvent();