Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .nx/version-plans/version-plan-1768506559411.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
__default__: prerelease
---

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.
14 changes: 14 additions & 0 deletions actions/android/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ inputs:
description: The project root directory
required: false
type: string
uploadVisualTestArtifacts:
description: Whether to upload visual test diff and actual images as artifacts
required: false
type: boolean
default: 'true'
runs:
using: 'composite'
steps:
Expand Down Expand Up @@ -112,3 +117,12 @@ runs:
echo $(pwd)
adb install -r ${{ inputs.app }}
pnpm react-native-harness --harnessRunner ${{ inputs.runner }}
- name: Upload visual test artifacts
if: always() && inputs.uploadVisualTestArtifacts == 'true'
uses: actions/upload-artifact@v4
with:
name: visual-test-diffs-android
path: |
${{ inputs.projectRoot }}/**/__image_snapshots__/**/*-diff.png
${{ inputs.projectRoot }}/**/__image_snapshots__/**/*-actual.png
if-no-files-found: ignore
14 changes: 14 additions & 0 deletions actions/ios/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ inputs:
description: The project root directory
required: false
type: string
uploadVisualTestArtifacts:
description: Whether to upload visual test diff and actual images as artifacts
required: false
type: boolean
default: 'true'
runs:
using: 'composite'
steps:
Expand Down Expand Up @@ -40,3 +45,12 @@ runs:
working-directory: ${{ inputs.projectRoot }}
run: |
pnpm react-native-harness --harnessRunner ${{ inputs.runner }}
- name: Upload visual test artifacts
if: always() && inputs.uploadVisualTestArtifacts == 'true'
uses: actions/upload-artifact@v4
with:
name: visual-test-diffs-ios
path: |
${{ inputs.projectRoot }}/**/__image_snapshots__/**/*-diff.png
${{ inputs.projectRoot }}/**/__image_snapshots__/**/*-actual.png
if-no-files-found: ignore
33 changes: 17 additions & 16 deletions actions/shared/index.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,9 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
mod
));

// ../../node_modules/.pnpm/picocolors@1.1.1/node_modules/picocolors/picocolors.js
// ../../node_modules/picocolors/picocolors.js
var require_picocolors = __commonJS({
"../../node_modules/.pnpm/picocolors@1.1.1/node_modules/picocolors/picocolors.js"(exports2, module2) {
"../../node_modules/picocolors/picocolors.js"(exports2, module2) {
"use strict";
var p = process || {};
var argv = p.argv || [];
Expand Down Expand Up @@ -102,9 +102,9 @@ var require_picocolors = __commonJS({
}
});

// ../../node_modules/.pnpm/sisteransi@1.0.5/node_modules/sisteransi/src/index.js
// ../../node_modules/sisteransi/src/index.js
var require_src = __commonJS({
"../../node_modules/.pnpm/sisteransi@1.0.5/node_modules/sisteransi/src/index.js"(exports2, module2) {
"../../node_modules/sisteransi/src/index.js"(exports2, module2) {
"use strict";
var ESC = "\x1B";
var CSI = `${ESC}[`;
Expand Down Expand Up @@ -158,9 +158,9 @@ var require_src = __commonJS({
}
});

// ../../node_modules/.pnpm/is-unicode-supported@0.1.0/node_modules/is-unicode-supported/index.js
// ../../node_modules/is-unicode-supported/index.js
var require_is_unicode_supported = __commonJS({
"../../node_modules/.pnpm/is-unicode-supported@0.1.0/node_modules/is-unicode-supported/index.js"(exports2, module2) {
"../../node_modules/is-unicode-supported/index.js"(exports2, module2) {
"use strict";
module2.exports = () => {
if (process.platform !== "win32") {
Expand All @@ -172,7 +172,7 @@ var require_is_unicode_supported = __commonJS({
}
});

// ../../node_modules/.pnpm/zod@3.25.67/node_modules/zod/dist/esm/v3/external.js
// ../../node_modules/zod/dist/esm/v3/external.js
var external_exports = {};
__export(external_exports, {
BRAND: () => BRAND,
Expand Down Expand Up @@ -284,7 +284,7 @@ __export(external_exports, {
void: () => voidType
});

// ../../node_modules/.pnpm/zod@3.25.67/node_modules/zod/dist/esm/v3/helpers/util.js
// ../../node_modules/zod/dist/esm/v3/helpers/util.js
var util;
(function(util3) {
util3.assertEqual = (_) => {
Expand Down Expand Up @@ -418,7 +418,7 @@ var getParsedType = (data) => {
}
};

// ../../node_modules/.pnpm/zod@3.25.67/node_modules/zod/dist/esm/v3/ZodError.js
// ../../node_modules/zod/dist/esm/v3/ZodError.js
var ZodIssueCode = util.arrayToEnum([
"invalid_type",
"invalid_literal",
Expand Down Expand Up @@ -535,7 +535,7 @@ ZodError.create = (issues) => {
return error;
};

// ../../node_modules/.pnpm/zod@3.25.67/node_modules/zod/dist/esm/v3/locales/en.js
// ../../node_modules/zod/dist/esm/v3/locales/en.js
var errorMap = (issue, _ctx) => {
let message;
switch (issue.code) {
Expand Down Expand Up @@ -636,7 +636,7 @@ var errorMap = (issue, _ctx) => {
};
var en_default = errorMap;

// ../../node_modules/.pnpm/zod@3.25.67/node_modules/zod/dist/esm/v3/errors.js
// ../../node_modules/zod/dist/esm/v3/errors.js
var overrideErrorMap = en_default;
function setErrorMap(map) {
overrideErrorMap = map;
Expand All @@ -645,7 +645,7 @@ function getErrorMap() {
return overrideErrorMap;
}

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

// ../../node_modules/.pnpm/zod@3.25.67/node_modules/zod/dist/esm/v3/helpers/errorUtil.js
// ../../node_modules/zod/dist/esm/v3/helpers/errorUtil.js
var errorUtil;
(function(errorUtil2) {
errorUtil2.errToObj = (message) => typeof message === "string" ? { message } : message || {};
errorUtil2.toString = (message) => typeof message === "string" ? message : message?.message;
})(errorUtil || (errorUtil = {}));

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

// ../../node_modules/.pnpm/@clack+core@1.0.0-alpha.5/node_modules/@clack/core/dist/index.mjs
// ../../node_modules/@clack/core/dist/index.mjs
var import_node_process = require("process");
var V = __toESM(require("readline"), 1);
var import_node_readline = __toESM(require("readline"), 1);
Expand All @@ -4251,7 +4252,7 @@ var C = { actions: new Set(gt), aliases: /* @__PURE__ */ new Map([["k", "up"], [
var At = globalThis.process.platform.startsWith("win");
var G = Symbol("clack:cancel");

// ../../node_modules/.pnpm/@clack+prompts@1.0.0-alpha.5/node_modules/@clack/prompts/dist/index.mjs
// ../../node_modules/@clack/prompts/dist/index.mjs
var import_picocolors = __toESM(require_picocolors(), 1);
var import_node_process2 = __toESM(require("process"), 1);
var import_node_fs = require("fs");
Expand Down
32 changes: 32 additions & 0 deletions apps/playground/ios/Podfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,34 @@ PODS:
- FBLazyVector (0.82.1)
- fmt (11.0.2)
- glog (0.3.5)
- HarnessUI (1.0.0-alpha.20):
- boost
- DoubleConversion
- fast_float
- fmt
- glog
- hermes-engine
- RCT-Folly
- RCT-Folly/Fabric
- RCTRequired
- RCTTypeSafety
- React-Core
- React-debug
- React-Fabric
- React-featureflags
- React-graphics
- React-ImageManager
- React-jsi
- React-NativeModulesApple
- React-RCTFabric
- React-renderercss
- React-rendererdebug
- React-utils
- ReactCodegen
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- SocketRocket
- Yoga
- hermes-engine (0.82.1):
- hermes-engine/Pre-built (= 0.82.1)
- hermes-engine/Pre-built (0.82.1)
Expand Down Expand Up @@ -2331,6 +2359,7 @@ DEPENDENCIES:
- FBLazyVector (from `../../../node_modules/react-native/Libraries/FBLazyVector`)
- fmt (from `../../../node_modules/react-native/third-party-podspecs/fmt.podspec`)
- glog (from `../../../node_modules/react-native/third-party-podspecs/glog.podspec`)
- "HarnessUI (from `../node_modules/@react-native-harness/ui`)"
- hermes-engine (from `../../../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec`)
- RCT-Folly (from `../../../node_modules/react-native/third-party-podspecs/RCT-Folly.podspec`)
- RCTDeprecation (from `../../../node_modules/react-native/ReactApple/Libraries/RCTFoundation/RCTDeprecation`)
Expand Down Expand Up @@ -2418,6 +2447,8 @@ EXTERNAL SOURCES:
:podspec: "../../../node_modules/react-native/third-party-podspecs/fmt.podspec"
glog:
:podspec: "../../../node_modules/react-native/third-party-podspecs/glog.podspec"
HarnessUI:
:path: "../node_modules/@react-native-harness/ui"
hermes-engine:
:podspec: "../../../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec"
:tag: hermes-2025-09-01-RNv0.82.0-265ef62ff3eb7289d17e366664ac0da82303e101
Expand Down Expand Up @@ -2561,6 +2592,7 @@ SPEC CHECKSUMS:
FBLazyVector: 0aa6183b9afe3c31fc65b5d1eeef1f3c19b63bfa
fmt: a40bb5bd0294ea969aaaba240a927bd33d878cdd
glog: 5683914934d5b6e4240e497e0f4a3b42d1854183
HarnessUI: 2957b94c9c4a7e6e54b636229f4aa5e3809936bf
hermes-engine: 273e30e7fb618279934b0b95ffab60ecedb7acf5
RCT-Folly: 846fda9475e61ec7bcbf8a3fe81edfcaeb090669
RCTDeprecation: f17e2ebc07876ca9ab8eb6e4b0a4e4647497ae3a
Expand Down
2 changes: 1 addition & 1 deletion apps/playground/jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ module.exports = {
setupFilesAfterEnv: ['./src/setupFileAfterEnv.ts'],
// This is necessary to prevent Jest from transforming the workspace packages.
// Not needed in users projects, as they will have the packages installed in their node_modules.
transformIgnorePatterns: ['/packages/'],
transformIgnorePatterns: ['/packages/', '/node_modules/'],
},
],
collectCoverageFrom: ['./src/**/*.(ts|tsx)'],
Expand Down
2 changes: 2 additions & 0 deletions apps/playground/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
},
"devDependencies": {
"react-native-harness": "workspace:*",
"@react-native-harness/runtime": "workspace:*",
"@react-native-harness/ui": "workspace:*",
"@react-native-community/cli": "20.0.0",
"@react-native-community/cli-platform-android": "20.0.0",
"@react-native-community/cli-platform-ios": "20.0.0",
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
33 changes: 33 additions & 0 deletions apps/playground/src/__tests__/ui/actions.harness.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { describe, test, render, fn, expect } from 'react-native-harness';
import { screen, userEvent } from '@react-native-harness/ui';
import { View, Text, Pressable } from 'react-native';

describe('Actions', () => {
test('should press element found by testID', async () => {
const onPress = fn();

await render(
<View
style={{
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: 'white',
}}
>
<Pressable
testID="this-is-test-id"
onPress={onPress}
style={{ padding: 10, backgroundColor: 'red' }}
>
<Text style={{ color: 'black' }}>This is a view with a testID</Text>
</Pressable>
</View>
);

const element = await screen.findByTestId('this-is-test-id');
await userEvent.press(element);

expect(onPress).toHaveBeenCalled();
});
});
34 changes: 34 additions & 0 deletions apps/playground/src/__tests__/ui/queries.harness.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { View, Text } from 'react-native';
import { describe, test, expect, render } from 'react-native-harness';
import { screen } from '@react-native-harness/ui';

describe('Queries', () => {
test('should find element by testID', async () => {
await render(
<View>
<View testID="this-is-test-id">
<Text>This is a view with a testID</Text>
</View>
</View>
);
const element = await screen.findByTestId('this-is-test-id');
expect(element).toBeDefined();
});

test('should find all elements by testID', async () => {
await render(
<View>
<View testID="this-is-test-id">
<Text>First element</Text>
</View>
<View testID="this-is-test-id">
<Text>Second element</Text>
</View>
</View>
);
const elements = await screen.findAllByTestId('this-is-test-id');
expect(elements).toBeDefined();
expect(Array.isArray(elements)).toBe(true);
expect(elements.length).toBe(2);
});
});
40 changes: 40 additions & 0 deletions apps/playground/src/__tests__/ui/screenshot.harness.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { describe, test, render, expect } from 'react-native-harness';
import { View, Text } from 'react-native';
import { screen } from '@react-native-harness/ui';

describe('Screenshot', () => {
test('should screenshot specific element only', async () => {
await render(
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
<View
style={{
width: 200,
height: 200,
backgroundColor: 'gray',
justifyContent: 'center',
alignItems: 'center',
}}
>
<View
testID="target-element"
style={{
width: 100,
height: 100,
backgroundColor: 'orange',
justifyContent: 'center',
alignItems: 'center',
}}
>
<Text style={{ color: 'white' }}>Target</Text>
</View>
</View>
</View>
);

const targetElement = await screen.findByTestId('target-element');
const screenshot = await screen.screenshot(targetElement);
await expect(screenshot).toMatchImageSnapshot({
name: 'orange-square-element-only',
});
});
});
Loading