Skip to content

Commit b5f855a

Browse files
authored
feat: UI testing API with queries and actions (#35)
Introduces UI testing capabilities with a new `@react-native-harness/ui` package that provides screen queries, user event simulation (press, type), and visual regression testing through `toMatchImageSnapshot`. This enables comprehensive component and integration testing with real device interactions, similar to React Testing Library but running on actual iOS and Android devices.
1 parent 274081c commit b5f855a

File tree

84 files changed

+3684
-885
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

84 files changed

+3684
-885
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
__default__: prerelease
3+
---
4+
5+
Introduces UI testing capabilities with a new `@react-native-harness/ui` package that provides screen queries, user event simulation (press, type), and visual regression testing through `toMatchImageSnapshot`. This enables comprehensive component and integration testing with real device interactions, similar to React Testing Library but running on actual iOS and Android devices.

actions/android/action.yml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@ inputs:
1212
description: The project root directory
1313
required: false
1414
type: string
15+
uploadVisualTestArtifacts:
16+
description: Whether to upload visual test diff and actual images as artifacts
17+
required: false
18+
type: boolean
19+
default: 'true'
1520
runs:
1621
using: 'composite'
1722
steps:
@@ -112,3 +117,12 @@ runs:
112117
echo $(pwd)
113118
adb install -r ${{ inputs.app }}
114119
pnpm react-native-harness --harnessRunner ${{ inputs.runner }}
120+
- name: Upload visual test artifacts
121+
if: always() && inputs.uploadVisualTestArtifacts == 'true'
122+
uses: actions/upload-artifact@v4
123+
with:
124+
name: visual-test-diffs-android
125+
path: |
126+
${{ inputs.projectRoot }}/**/__image_snapshots__/**/*-diff.png
127+
${{ inputs.projectRoot }}/**/__image_snapshots__/**/*-actual.png
128+
if-no-files-found: ignore

actions/ios/action.yml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@ inputs:
1212
description: The project root directory
1313
required: false
1414
type: string
15+
uploadVisualTestArtifacts:
16+
description: Whether to upload visual test diff and actual images as artifacts
17+
required: false
18+
type: boolean
19+
default: 'true'
1520
runs:
1621
using: 'composite'
1722
steps:
@@ -40,3 +45,12 @@ runs:
4045
working-directory: ${{ inputs.projectRoot }}
4146
run: |
4247
pnpm react-native-harness --harnessRunner ${{ inputs.runner }}
48+
- name: Upload visual test artifacts
49+
if: always() && inputs.uploadVisualTestArtifacts == 'true'
50+
uses: actions/upload-artifact@v4
51+
with:
52+
name: visual-test-diffs-ios
53+
path: |
54+
${{ inputs.projectRoot }}/**/__image_snapshots__/**/*-diff.png
55+
${{ inputs.projectRoot }}/**/__image_snapshots__/**/*-actual.png
56+
if-no-files-found: ignore

actions/shared/index.cjs

Lines changed: 17 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,9 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
2929
mod
3030
));
3131

32-
// ../../node_modules/.pnpm/[email protected]/node_modules/picocolors/picocolors.js
32+
// ../../node_modules/picocolors/picocolors.js
3333
var require_picocolors = __commonJS({
34-
"../../node_modules/.pnpm/[email protected]/node_modules/picocolors/picocolors.js"(exports2, module2) {
34+
"../../node_modules/picocolors/picocolors.js"(exports2, module2) {
3535
"use strict";
3636
var p = process || {};
3737
var argv = p.argv || [];
@@ -102,9 +102,9 @@ var require_picocolors = __commonJS({
102102
}
103103
});
104104

105-
// ../../node_modules/.pnpm/[email protected]/node_modules/sisteransi/src/index.js
105+
// ../../node_modules/sisteransi/src/index.js
106106
var require_src = __commonJS({
107-
"../../node_modules/.pnpm/[email protected]/node_modules/sisteransi/src/index.js"(exports2, module2) {
107+
"../../node_modules/sisteransi/src/index.js"(exports2, module2) {
108108
"use strict";
109109
var ESC = "\x1B";
110110
var CSI = `${ESC}[`;
@@ -158,9 +158,9 @@ var require_src = __commonJS({
158158
}
159159
});
160160

161-
// ../../node_modules/.pnpm/[email protected]/node_modules/is-unicode-supported/index.js
161+
// ../../node_modules/is-unicode-supported/index.js
162162
var require_is_unicode_supported = __commonJS({
163-
"../../node_modules/.pnpm/[email protected]/node_modules/is-unicode-supported/index.js"(exports2, module2) {
163+
"../../node_modules/is-unicode-supported/index.js"(exports2, module2) {
164164
"use strict";
165165
module2.exports = () => {
166166
if (process.platform !== "win32") {
@@ -172,7 +172,7 @@ var require_is_unicode_supported = __commonJS({
172172
}
173173
});
174174

175-
// ../../node_modules/.pnpm/[email protected]/node_modules/zod/dist/esm/v3/external.js
175+
// ../../node_modules/zod/dist/esm/v3/external.js
176176
var external_exports = {};
177177
__export(external_exports, {
178178
BRAND: () => BRAND,
@@ -284,7 +284,7 @@ __export(external_exports, {
284284
void: () => voidType
285285
});
286286

287-
// ../../node_modules/.pnpm/[email protected]/node_modules/zod/dist/esm/v3/helpers/util.js
287+
// ../../node_modules/zod/dist/esm/v3/helpers/util.js
288288
var util;
289289
(function(util3) {
290290
util3.assertEqual = (_) => {
@@ -418,7 +418,7 @@ var getParsedType = (data) => {
418418
}
419419
};
420420

421-
// ../../node_modules/.pnpm/[email protected]/node_modules/zod/dist/esm/v3/ZodError.js
421+
// ../../node_modules/zod/dist/esm/v3/ZodError.js
422422
var ZodIssueCode = util.arrayToEnum([
423423
"invalid_type",
424424
"invalid_literal",
@@ -535,7 +535,7 @@ ZodError.create = (issues) => {
535535
return error;
536536
};
537537

538-
// ../../node_modules/.pnpm/[email protected]/node_modules/zod/dist/esm/v3/locales/en.js
538+
// ../../node_modules/zod/dist/esm/v3/locales/en.js
539539
var errorMap = (issue, _ctx) => {
540540
let message;
541541
switch (issue.code) {
@@ -636,7 +636,7 @@ var errorMap = (issue, _ctx) => {
636636
};
637637
var en_default = errorMap;
638638

639-
// ../../node_modules/.pnpm/[email protected]/node_modules/zod/dist/esm/v3/errors.js
639+
// ../../node_modules/zod/dist/esm/v3/errors.js
640640
var overrideErrorMap = en_default;
641641
function setErrorMap(map) {
642642
overrideErrorMap = map;
@@ -645,7 +645,7 @@ function getErrorMap() {
645645
return overrideErrorMap;
646646
}
647647

648-
// ../../node_modules/.pnpm/[email protected]/node_modules/zod/dist/esm/v3/helpers/parseUtil.js
648+
// ../../node_modules/zod/dist/esm/v3/helpers/parseUtil.js
649649
var makeIssue = (params) => {
650650
const { data, path: path4, errorMaps, issueData } = params;
651651
const fullPath = [...path4, ...issueData.path || []];
@@ -755,14 +755,14 @@ var isDirty = (x) => x.status === "dirty";
755755
var isValid = (x) => x.status === "valid";
756756
var isAsync = (x) => typeof Promise !== "undefined" && x instanceof Promise;
757757

758-
// ../../node_modules/.pnpm/[email protected]/node_modules/zod/dist/esm/v3/helpers/errorUtil.js
758+
// ../../node_modules/zod/dist/esm/v3/helpers/errorUtil.js
759759
var errorUtil;
760760
(function(errorUtil2) {
761761
errorUtil2.errToObj = (message) => typeof message === "string" ? { message } : message || {};
762762
errorUtil2.toString = (message) => typeof message === "string" ? message : message?.message;
763763
})(errorUtil || (errorUtil = {}));
764764

765-
// ../../node_modules/.pnpm/[email protected]/node_modules/zod/dist/esm/v3/types.js
765+
// ../../node_modules/zod/dist/esm/v3/types.js
766766
var ParseInputLazyPath = class {
767767
constructor(parent, value, path4, key) {
768768
this._cachedPath = [];
@@ -4214,6 +4214,7 @@ var ConfigSchema = external_exports.object({
42144214
appRegistryComponentName: external_exports.string().min(1, "App registry component name is required"),
42154215
runners: external_exports.array(external_exports.any()).min(1, "At least one runner is required"),
42164216
defaultRunner: external_exports.string().optional(),
4217+
webSocketPort: external_exports.number().optional().default(3001),
42174218
bridgeTimeout: external_exports.number().min(1e3, "Bridge timeout must be at least 1 second").default(6e4),
42184219
resetEnvironmentBetweenTestFiles: external_exports.boolean().optional().default(true),
42194220
unstable__skipAlreadyIncludedModules: external_exports.boolean().optional().default(false),
@@ -4233,7 +4234,7 @@ var ConfigSchema = external_exports.object({
42334234
// ../tools/dist/logger.js
42344235
var import_node_util2 = __toESM(require("util"), 1);
42354236

4236-
// ../../node_modules/.pnpm/@clack[email protected]/node_modules/@clack/core/dist/index.mjs
4237+
// ../../node_modules/@clack/core/dist/index.mjs
42374238
var import_node_process = require("process");
42384239
var V = __toESM(require("readline"), 1);
42394240
var import_node_readline = __toESM(require("readline"), 1);
@@ -4251,7 +4252,7 @@ var C = { actions: new Set(gt), aliases: /* @__PURE__ */ new Map([["k", "up"], [
42514252
var At = globalThis.process.platform.startsWith("win");
42524253
var G = Symbol("clack:cancel");
42534254

4254-
// ../../node_modules/.pnpm/@clack[email protected]/node_modules/@clack/prompts/dist/index.mjs
4255+
// ../../node_modules/@clack/prompts/dist/index.mjs
42554256
var import_picocolors = __toESM(require_picocolors(), 1);
42564257
var import_node_process2 = __toESM(require("process"), 1);
42574258
var import_node_fs = require("fs");

apps/playground/ios/Podfile.lock

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,34 @@ PODS:
55
- FBLazyVector (0.82.1)
66
- fmt (11.0.2)
77
- glog (0.3.5)
8+
- HarnessUI (1.0.0-alpha.20):
9+
- boost
10+
- DoubleConversion
11+
- fast_float
12+
- fmt
13+
- glog
14+
- hermes-engine
15+
- RCT-Folly
16+
- RCT-Folly/Fabric
17+
- RCTRequired
18+
- RCTTypeSafety
19+
- React-Core
20+
- React-debug
21+
- React-Fabric
22+
- React-featureflags
23+
- React-graphics
24+
- React-ImageManager
25+
- React-jsi
26+
- React-NativeModulesApple
27+
- React-RCTFabric
28+
- React-renderercss
29+
- React-rendererdebug
30+
- React-utils
31+
- ReactCodegen
32+
- ReactCommon/turbomodule/bridging
33+
- ReactCommon/turbomodule/core
34+
- SocketRocket
35+
- Yoga
836
- hermes-engine (0.82.1):
937
- hermes-engine/Pre-built (= 0.82.1)
1038
- hermes-engine/Pre-built (0.82.1)
@@ -2331,6 +2359,7 @@ DEPENDENCIES:
23312359
- FBLazyVector (from `../../../node_modules/react-native/Libraries/FBLazyVector`)
23322360
- fmt (from `../../../node_modules/react-native/third-party-podspecs/fmt.podspec`)
23332361
- glog (from `../../../node_modules/react-native/third-party-podspecs/glog.podspec`)
2362+
- "HarnessUI (from `../node_modules/@react-native-harness/ui`)"
23342363
- hermes-engine (from `../../../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec`)
23352364
- RCT-Folly (from `../../../node_modules/react-native/third-party-podspecs/RCT-Folly.podspec`)
23362365
- RCTDeprecation (from `../../../node_modules/react-native/ReactApple/Libraries/RCTFoundation/RCTDeprecation`)
@@ -2418,6 +2447,8 @@ EXTERNAL SOURCES:
24182447
:podspec: "../../../node_modules/react-native/third-party-podspecs/fmt.podspec"
24192448
glog:
24202449
:podspec: "../../../node_modules/react-native/third-party-podspecs/glog.podspec"
2450+
HarnessUI:
2451+
:path: "../node_modules/@react-native-harness/ui"
24212452
hermes-engine:
24222453
:podspec: "../../../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec"
24232454
:tag: hermes-2025-09-01-RNv0.82.0-265ef62ff3eb7289d17e366664ac0da82303e101
@@ -2561,6 +2592,7 @@ SPEC CHECKSUMS:
25612592
FBLazyVector: 0aa6183b9afe3c31fc65b5d1eeef1f3c19b63bfa
25622593
fmt: a40bb5bd0294ea969aaaba240a927bd33d878cdd
25632594
glog: 5683914934d5b6e4240e497e0f4a3b42d1854183
2595+
HarnessUI: 2957b94c9c4a7e6e54b636229f4aa5e3809936bf
25642596
hermes-engine: 273e30e7fb618279934b0b95ffab60ecedb7acf5
25652597
RCT-Folly: 846fda9475e61ec7bcbf8a3fe81edfcaeb090669
25662598
RCTDeprecation: f17e2ebc07876ca9ab8eb6e4b0a4e4647497ae3a

apps/playground/jest.config.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ module.exports = {
1010
setupFilesAfterEnv: ['./src/setupFileAfterEnv.ts'],
1111
// This is necessary to prevent Jest from transforming the workspace packages.
1212
// Not needed in users projects, as they will have the packages installed in their node_modules.
13-
transformIgnorePatterns: ['/packages/'],
13+
transformIgnorePatterns: ['/packages/', '/node_modules/'],
1414
},
1515
],
1616
collectCoverageFrom: ['./src/**/*.(ts|tsx)'],

apps/playground/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
},
1212
"devDependencies": {
1313
"react-native-harness": "workspace:*",
14+
"@react-native-harness/runtime": "workspace:*",
15+
"@react-native-harness/ui": "workspace:*",
1416
"@react-native-community/cli": "20.0.0",
1517
"@react-native-community/cli-platform-android": "20.0.0",
1618
"@react-native-community/cli-platform-ios": "20.0.0",
3.06 KB
Loading
5.73 KB
Loading
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { describe, test, render, fn, expect } from 'react-native-harness';
2+
import { screen, userEvent } from '@react-native-harness/ui';
3+
import { View, Text, Pressable } from 'react-native';
4+
5+
describe('Actions', () => {
6+
test('should press element found by testID', async () => {
7+
const onPress = fn();
8+
9+
await render(
10+
<View
11+
style={{
12+
flex: 1,
13+
justifyContent: 'center',
14+
alignItems: 'center',
15+
backgroundColor: 'white',
16+
}}
17+
>
18+
<Pressable
19+
testID="this-is-test-id"
20+
onPress={onPress}
21+
style={{ padding: 10, backgroundColor: 'red' }}
22+
>
23+
<Text style={{ color: 'black' }}>This is a view with a testID</Text>
24+
</Pressable>
25+
</View>
26+
);
27+
28+
const element = await screen.findByTestId('this-is-test-id');
29+
await userEvent.press(element);
30+
31+
expect(onPress).toHaveBeenCalled();
32+
});
33+
});

0 commit comments

Comments
 (0)