diff --git a/.nx/version-plans/version-plan-1768506559411.md b/.nx/version-plans/version-plan-1768506559411.md
new file mode 100644
index 0000000..e553a99
--- /dev/null
+++ b/.nx/version-plans/version-plan-1768506559411.md
@@ -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.
diff --git a/actions/android/action.yml b/actions/android/action.yml
index 820df9e..5c4e5a4 100644
--- a/actions/android/action.yml
+++ b/actions/android/action.yml
@@ -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:
@@ -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
diff --git a/actions/ios/action.yml b/actions/ios/action.yml
index 173c923..706d573 100644
--- a/actions/ios/action.yml
+++ b/actions/ios/action.yml
@@ -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:
@@ -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
diff --git a/actions/shared/index.cjs b/actions/shared/index.cjs
index c1de695..41e40ce 100644
--- a/actions/shared/index.cjs
+++ b/actions/shared/index.cjs
@@ -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 || [];
@@ -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}[`;
@@ -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") {
@@ -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,
@@ -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 = (_) => {
@@ -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",
@@ -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) {
@@ -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;
@@ -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 || []];
@@ -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 = [];
@@ -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),
@@ -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);
@@ -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");
diff --git a/apps/playground/ios/Podfile.lock b/apps/playground/ios/Podfile.lock
index bdbb4b4..22b1c53 100644
--- a/apps/playground/ios/Podfile.lock
+++ b/apps/playground/ios/Podfile.lock
@@ -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)
@@ -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`)
@@ -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
@@ -2561,6 +2592,7 @@ SPEC CHECKSUMS:
FBLazyVector: 0aa6183b9afe3c31fc65b5d1eeef1f3c19b63bfa
fmt: a40bb5bd0294ea969aaaba240a927bd33d878cdd
glog: 5683914934d5b6e4240e497e0f4a3b42d1854183
+ HarnessUI: 2957b94c9c4a7e6e54b636229f4aa5e3809936bf
hermes-engine: 273e30e7fb618279934b0b95ffab60ecedb7acf5
RCT-Folly: 846fda9475e61ec7bcbf8a3fe81edfcaeb090669
RCTDeprecation: f17e2ebc07876ca9ab8eb6e4b0a4e4647497ae3a
diff --git a/apps/playground/jest.config.js b/apps/playground/jest.config.js
index 982a675..d785bdd 100644
--- a/apps/playground/jest.config.js
+++ b/apps/playground/jest.config.js
@@ -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)'],
diff --git a/apps/playground/package.json b/apps/playground/package.json
index 9bef03f..6b780fa 100644
--- a/apps/playground/package.json
+++ b/apps/playground/package.json
@@ -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",
diff --git a/apps/playground/src/__tests__/ui/__image_snapshots__/android/orange-square-element-only.png b/apps/playground/src/__tests__/ui/__image_snapshots__/android/orange-square-element-only.png
new file mode 100644
index 0000000..0fe0b77
Binary files /dev/null and b/apps/playground/src/__tests__/ui/__image_snapshots__/android/orange-square-element-only.png differ
diff --git a/apps/playground/src/__tests__/ui/__image_snapshots__/ios/orange-square-element-only.png b/apps/playground/src/__tests__/ui/__image_snapshots__/ios/orange-square-element-only.png
new file mode 100644
index 0000000..6d7bf08
Binary files /dev/null and b/apps/playground/src/__tests__/ui/__image_snapshots__/ios/orange-square-element-only.png differ
diff --git a/apps/playground/src/__tests__/ui/actions.harness.tsx b/apps/playground/src/__tests__/ui/actions.harness.tsx
new file mode 100644
index 0000000..c7685d7
--- /dev/null
+++ b/apps/playground/src/__tests__/ui/actions.harness.tsx
@@ -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(
+
+
+ This is a view with a testID
+
+
+ );
+
+ const element = await screen.findByTestId('this-is-test-id');
+ await userEvent.press(element);
+
+ expect(onPress).toHaveBeenCalled();
+ });
+});
diff --git a/apps/playground/src/__tests__/ui/queries.harness.tsx b/apps/playground/src/__tests__/ui/queries.harness.tsx
new file mode 100644
index 0000000..f346128
--- /dev/null
+++ b/apps/playground/src/__tests__/ui/queries.harness.tsx
@@ -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(
+
+
+ This is a view with a testID
+
+
+ );
+ const element = await screen.findByTestId('this-is-test-id');
+ expect(element).toBeDefined();
+ });
+
+ test('should find all elements by testID', async () => {
+ await render(
+
+
+ First element
+
+
+ Second element
+
+
+ );
+ const elements = await screen.findAllByTestId('this-is-test-id');
+ expect(elements).toBeDefined();
+ expect(Array.isArray(elements)).toBe(true);
+ expect(elements.length).toBe(2);
+ });
+});
diff --git a/apps/playground/src/__tests__/ui/screenshot.harness.tsx b/apps/playground/src/__tests__/ui/screenshot.harness.tsx
new file mode 100644
index 0000000..78e44eb
--- /dev/null
+++ b/apps/playground/src/__tests__/ui/screenshot.harness.tsx
@@ -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(
+
+
+
+ Target
+
+
+
+ );
+
+ const targetElement = await screen.findByTestId('target-element');
+ const screenshot = await screen.screenshot(targetElement);
+ await expect(screenshot).toMatchImageSnapshot({
+ name: 'orange-square-element-only',
+ });
+ });
+});
diff --git a/apps/playground/src/__tests__/ui/type.harness.tsx b/apps/playground/src/__tests__/ui/type.harness.tsx
new file mode 100644
index 0000000..5ededbe
--- /dev/null
+++ b/apps/playground/src/__tests__/ui/type.harness.tsx
@@ -0,0 +1,124 @@
+import { describe, test, render, fn, expect } from 'react-native-harness';
+import { screen, userEvent } from '@react-native-harness/ui';
+import { View, TextInput } from 'react-native';
+
+describe('userEvent.type', () => {
+ test('should type text into TextInput and trigger onChangeText', async () => {
+ const onChangeText = fn();
+
+ await render(
+
+
+
+ );
+
+ const textInput = await screen.findByTestId('text-input');
+ await userEvent.type(textInput, 'Hello');
+
+ // onChangeText should be called for each character
+ expect(onChangeText).toHaveBeenCalledTimes(5);
+
+ // Verify the progressive text changes
+ expect(onChangeText).toHaveBeenNthCalledWith(1, 'H');
+ expect(onChangeText).toHaveBeenNthCalledWith(2, 'He');
+ expect(onChangeText).toHaveBeenNthCalledWith(3, 'Hel');
+ expect(onChangeText).toHaveBeenNthCalledWith(4, 'Hell');
+ expect(onChangeText).toHaveBeenNthCalledWith(5, 'Hello');
+ });
+
+ test('should append to existing text', async () => {
+ const onChangeText = fn();
+
+ await render(
+
+
+
+ );
+
+ const textInput = await screen.findByTestId('text-input');
+ await userEvent.type(textInput, 'there');
+
+ // Should append to existing "Hi " text
+ expect(onChangeText).toHaveBeenLastCalledWith('Hi there');
+ });
+
+ test('should trigger onBlur when typing completes', async () => {
+ const onBlur = fn();
+
+ await render(
+
+
+
+ );
+
+ const textInput = await screen.findByTestId('text-input');
+ await userEvent.type(textInput, 'test');
+
+ expect(onBlur).toHaveBeenCalled();
+ });
+
+ test('should not trigger blur when skipBlur is true', async () => {
+ const onBlur = fn();
+
+ await render(
+
+
+
+ );
+
+ const textInput = await screen.findByTestId('text-input');
+ await userEvent.type(textInput, 'test', { skipBlur: true });
+
+ expect(onBlur).not.toHaveBeenCalled();
+ });
+
+ test('should trigger onSubmitEditing when submitEditing option is true', async () => {
+ const onSubmitEditing = fn();
+
+ await render(
+
+
+
+ );
+
+ const textInput = await screen.findByTestId('text-input');
+ await userEvent.type(textInput, 'test', { submitEditing: true });
+
+ expect(onSubmitEditing).toHaveBeenCalled();
+ });
+});
diff --git a/apps/playground/tsconfig.app.json b/apps/playground/tsconfig.app.json
index 3b6ab83..a90580b 100644
--- a/apps/playground/tsconfig.app.json
+++ b/apps/playground/tsconfig.app.json
@@ -25,13 +25,7 @@
"src/**/*.spec.jsx",
"src/test-setup.ts"
],
- "include": [
- "src/**/*.ts",
- "src/**/*.tsx",
- "src/**/*.js",
- "src/**/*.jsx",
- "react-native-harness.d.ts"
- ],
+ "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.js", "src/**/*.jsx"],
"references": [
{
"path": "../../packages/platform-vega/tsconfig.lib.json"
@@ -45,6 +39,12 @@
{
"path": "../../packages/jest/tsconfig.lib.json"
},
+ {
+ "path": "../../packages/ui/tsconfig.lib.json"
+ },
+ {
+ "path": "../../packages/runtime/tsconfig.lib.json"
+ },
{
"path": "../../packages/react-native-harness/tsconfig.lib.json"
}
diff --git a/apps/playground/tsconfig.json b/apps/playground/tsconfig.json
index b495afc..fc60f68 100644
--- a/apps/playground/tsconfig.json
+++ b/apps/playground/tsconfig.json
@@ -15,6 +15,12 @@
{
"path": "../../packages/jest"
},
+ {
+ "path": "../../packages/ui"
+ },
+ {
+ "path": "../../packages/runtime"
+ },
{
"path": "../../packages/react-native-harness"
},
diff --git a/packages/bridge/package.json b/packages/bridge/package.json
index 9a95328..25f6a9b 100644
--- a/packages/bridge/package.json
+++ b/packages/bridge/package.json
@@ -27,12 +27,18 @@
}
},
"dependencies": {
+ "@react-native-harness/platforms": "workspace:*",
"@react-native-harness/tools": "workspace:*",
"birpc": "^2.4.0",
+ "pixelmatch": "^7.1.0",
+ "pngjs": "^7.0.0",
+ "ssim.js": "^3.5.0",
"tslib": "^2.3.0",
"ws": "^8.18.2"
},
"devDependencies": {
+ "@types/pixelmatch": "^5.2.6",
+ "@types/pngjs": "^6.0.5",
"@types/ws": "^8.18.1"
},
"license": "MIT"
diff --git a/packages/bridge/src/binary-transfer.ts b/packages/bridge/src/binary-transfer.ts
new file mode 100644
index 0000000..c2d6f85
--- /dev/null
+++ b/packages/bridge/src/binary-transfer.ts
@@ -0,0 +1,79 @@
+export const HEADER_SIZE = 8;
+
+export function createBinaryFrame(
+ transferId: number,
+ data: Uint8Array
+): Uint8Array {
+ const frame = new Uint8Array(HEADER_SIZE + data.length);
+ const view = new DataView(frame.buffer);
+
+ // Transfer ID (4 bytes, Big Endian)
+ view.setUint32(0, transferId, false);
+
+ // Reserved (4 bytes) - set to 0
+ view.setUint32(4, 0, false);
+
+ // Copy data
+ frame.set(data, HEADER_SIZE);
+
+ return frame;
+}
+
+export function parseBinaryFrame(frame: Uint8Array): {
+ transferId: number;
+ data: Uint8Array;
+} {
+ const view = new DataView(frame.buffer, frame.byteOffset, frame.byteLength);
+ const transferId = view.getUint32(0, false);
+ const data = frame.subarray(HEADER_SIZE);
+
+ return { transferId, data };
+}
+
+export class BinaryStore {
+ private store = new Map();
+ private timeouts = new Map();
+ // 5 minutes timeout for binary data
+ private readonly TIMEOUT_MS = 5 * 60 * 1000;
+
+ add(transferId: number, data: Uint8Array): void {
+ this.store.set(transferId, data);
+ const timeout = setTimeout(() => {
+ this.store.delete(transferId);
+ this.timeouts.delete(transferId);
+ }, this.TIMEOUT_MS);
+ this.timeouts.set(transferId, timeout);
+ }
+
+ get(transferId: number): Uint8Array | undefined {
+ return this.store.get(transferId);
+ }
+
+ delete(transferId: number): boolean {
+ const timeout = this.timeouts.get(transferId);
+ if (timeout) {
+ clearTimeout(timeout);
+ this.timeouts.delete(transferId);
+ }
+ return this.store.delete(transferId);
+ }
+
+ dispose(): void {
+ for (const timeout of this.timeouts.values()) {
+ clearTimeout(timeout);
+ }
+ this.timeouts.clear();
+ this.store.clear();
+ }
+}
+
+let nextTransferId = 1;
+export function generateTransferId(): number {
+ // Use a rolling counter, but ensure it doesn't overflow 32-bit integer just in case
+ // though JS numbers are doubles, we are writing to Uint32.
+ const id = nextTransferId++;
+ if (nextTransferId > 0xffffffff) {
+ nextTransferId = 1;
+ }
+ return id;
+}
diff --git a/packages/bridge/src/client.ts b/packages/bridge/src/client.ts
index 6e80ff0..ceb3af3 100644
--- a/packages/bridge/src/client.ts
+++ b/packages/bridge/src/client.ts
@@ -1,10 +1,12 @@
import { BirpcReturn, createBirpc } from 'birpc';
import type { BridgeClientFunctions, BridgeServerFunctions } from './shared.js';
import { deserialize, serialize } from './serializer.js';
+import { createBinaryFrame } from './binary-transfer.js';
export type BridgeClient = {
rpc: BirpcReturn;
disconnect: () => void;
+ sendBinary: (transferId: number, data: Uint8Array) => void;
};
const getBridgeClient = async (
@@ -13,6 +15,7 @@ const getBridgeClient = async (
): Promise => {
return new Promise((resolve) => {
const ws = new WebSocket(url);
+ ws.binaryType = 'arraybuffer';
const handleOpen = () => {
const rpc = createBirpc(
@@ -21,7 +24,9 @@ const getBridgeClient = async (
post: (data) => ws.send(data),
on: (handler) => {
ws.addEventListener('message', (event: any) => {
- handler(event.data);
+ if (typeof event.data === 'string') {
+ handler(event.data);
+ }
});
},
serialize,
@@ -34,6 +39,10 @@ const getBridgeClient = async (
disconnect: () => {
ws.close();
},
+ sendBinary: (transferId: number, data: Uint8Array) => {
+ const frame = createBinaryFrame(transferId, data);
+ ws.send(frame);
+ },
};
resolve(client);
diff --git a/packages/bridge/src/image-snapshot.ts b/packages/bridge/src/image-snapshot.ts
new file mode 100644
index 0000000..332d40f
--- /dev/null
+++ b/packages/bridge/src/image-snapshot.ts
@@ -0,0 +1,170 @@
+import pixelmatch from 'pixelmatch';
+import { ssim } from 'ssim.js';
+import fs from 'node:fs/promises';
+import path from 'node:path';
+import { PNG } from 'pngjs';
+import type { FileReference, ImageSnapshotOptions } from './shared.js';
+
+type PixelmatchOptions = Parameters[5];
+
+const SNAPSHOT_DIR_NAME = '__image_snapshots__';
+const DEFAULT_OPTIONS_FOR_PIXELMATCH: PixelmatchOptions = {
+ threshold: 0.1,
+ includeAA: false,
+ alpha: 0.1,
+ aaColor: [255, 255, 0],
+ diffColor: [255, 0, 0],
+ // @ts-expect-error - this is extracted from the pixelmatch package
+ diffColorAlt: null,
+ diffMask: false,
+};
+
+function maskRegions(
+ data: Buffer,
+ imageWidth: number,
+ regions: Array<{ x: number; y: number; width: number; height: number }>
+) {
+ for (const region of regions) {
+ const startY = Math.max(0, region.y);
+ const endY = Math.min(
+ Math.floor(data.length / 4 / imageWidth),
+ region.y + region.height
+ );
+ const startX = Math.max(0, region.x);
+ const endX = Math.min(imageWidth, region.x + region.width);
+
+ for (let y = startY; y < endY; y++) {
+ for (let x = startX; x < endX; x++) {
+ const idx = (imageWidth * y + x) << 2;
+ data[idx] = 0;
+ data[idx + 1] = 0;
+ data[idx + 2] = 0;
+ data[idx + 3] = 0;
+ }
+ }
+ }
+}
+
+export const matchImageSnapshot = async (
+ screenshot: FileReference,
+ testFilePath: string,
+ options: ImageSnapshotOptions,
+ platformName: string
+) => {
+ const pixelmatchOptions = {
+ ...DEFAULT_OPTIONS_FOR_PIXELMATCH,
+ ...options,
+ };
+ const receivedPath = screenshot.path;
+
+ try {
+ await fs.access(receivedPath);
+ } catch {
+ throw new Error(`Screenshot file not found at ${receivedPath}`);
+ }
+
+ const receivedBuffer = await fs.readFile(receivedPath);
+
+ // Create __image_snapshots__ directory in same directory as test file
+ const testDir = path.dirname(testFilePath);
+ const snapshotsDir = path.join(testDir, SNAPSHOT_DIR_NAME, platformName);
+
+ const snapshotName = `${options.name}.png`;
+ const snapshotPath = path.join(snapshotsDir, snapshotName);
+
+ await fs.mkdir(snapshotsDir, { recursive: true });
+
+ try {
+ await fs.access(snapshotPath);
+ } catch {
+ // First time - create snapshot
+ await fs.writeFile(snapshotPath, receivedBuffer);
+ return {
+ pass: true,
+ message: `Snapshot created at ${snapshotPath}`,
+ };
+ }
+
+ const [receivedBufferAgain, snapshotBuffer] = await Promise.all([
+ fs.readFile(receivedPath),
+ fs.readFile(snapshotPath),
+ ]);
+ const img1 = PNG.sync.read(receivedBufferAgain);
+ const img2 = PNG.sync.read(snapshotBuffer);
+ const { width, height } = img1;
+ const diff = new PNG({ width, height });
+
+ if (img1.width !== img2.width || img1.height !== img2.height) {
+ return {
+ pass: false,
+ message: `Images have different dimensions. Received image width: ${img1.width}, height: ${img1.height}. Snapshot image width: ${img2.width}, height: ${img2.height}.`,
+ };
+ }
+
+ if (options.ignoreRegions) {
+ maskRegions(img1.data, width, options.ignoreRegions);
+ maskRegions(img2.data, width, options.ignoreRegions);
+ }
+
+ let pass = false;
+ let message = '';
+ // Always calculate pixel differences for visual diff
+ const differences = pixelmatch(
+ img1.data,
+ img2.data,
+ diff.data,
+ width,
+ height,
+ pixelmatchOptions
+ );
+
+ if (options.comparisonMethod === 'ssim') {
+ const img1Data = {
+ data: new Uint8ClampedArray(img1.data),
+ width: img1.width,
+ height: img1.height,
+ };
+ const img2Data = {
+ data: new Uint8ClampedArray(img2.data),
+ width: img2.width,
+ height: img2.height,
+ };
+ const { mssim } = ssim(img1Data, img2Data);
+ const threshold = options.ssimThreshold ?? 0.95;
+ pass = mssim >= threshold;
+ message = pass
+ ? `Images match (SSIM: ${mssim})`
+ : `SSIM score ${mssim} is less than threshold ${threshold}`;
+ } else {
+ const failureThreshold = options.failureThreshold ?? 0;
+ const failureThresholdType = options.failureThresholdType ?? 'pixel';
+
+ if (failureThresholdType === 'percent') {
+ const totalPixels = width * height;
+ const percentage = differences / totalPixels;
+ pass = percentage <= failureThreshold;
+ } else {
+ pass = differences <= failureThreshold;
+ }
+
+ message = pass ? 'Images match' : `Images differ by ${differences} pixels.`;
+ }
+
+ // Save diff and actual images when test fails
+ if (!pass) {
+ const diffFileName = `${snapshotName.replace('.png', '')}-diff.png`;
+ const diffPath = path.join(snapshotsDir, diffFileName);
+ await fs.writeFile(diffPath, PNG.sync.write(diff));
+
+ const actualFileName = `${snapshotName.replace('.png', '')}-actual.png`;
+ const actualPath = path.join(snapshotsDir, actualFileName);
+ await fs.writeFile(actualPath, receivedBuffer);
+
+ message += ` Diff saved at ${diffPath}. Actual image saved at ${actualPath}.`;
+ }
+
+ return {
+ pass,
+ message,
+ };
+};
diff --git a/packages/bridge/src/index.ts b/packages/bridge/src/index.ts
index 8180878..74a8ed6 100644
--- a/packages/bridge/src/index.ts
+++ b/packages/bridge/src/index.ts
@@ -1 +1,2 @@
export * from './shared.js';
+export * from './binary-transfer.js';
diff --git a/packages/bridge/src/server.ts b/packages/bridge/src/server.ts
index af45378..ab4b97f 100644
--- a/packages/bridge/src/server.ts
+++ b/packages/bridge/src/server.ts
@@ -2,18 +2,31 @@ import { WebSocketServer, type WebSocket } from 'ws';
import { type BirpcGroup, createBirpcGroup } from 'birpc';
import { logger } from '@react-native-harness/tools';
import { EventEmitter } from 'node:events';
+import fs from 'node:fs/promises';
+import os from 'node:os';
+import path from 'node:path';
+import { randomUUID } from 'node:crypto';
+import { BinaryStore, parseBinaryFrame } from './binary-transfer.js';
import type {
BridgeServerFunctions,
BridgeClientFunctions,
DeviceDescriptor,
BridgeEvents,
+ ImageSnapshotOptions,
+ HarnessContext,
+ BinaryDataReference,
+ FileReference,
} from './shared.js';
import { deserialize, serialize } from './serializer.js';
import { DeviceNotRespondingError } from './errors.js';
+import { matchImageSnapshot } from './image-snapshot.js';
+
+export { DeviceNotRespondingError } from './errors.js';
export type BridgeServerOptions = {
port: number;
timeout?: number;
+ context: HarnessContext;
};
export type BridgeServerEvents = {
@@ -43,6 +56,7 @@ export type BridgeServer = {
export const getBridgeServer = async ({
port,
timeout,
+ context,
}: BridgeServerOptions): Promise => {
const wss = await new Promise((resolve) => {
const server = new WebSocketServer({ port, host: '0.0.0.0' }, () => {
@@ -51,19 +65,65 @@ export const getBridgeServer = async ({
});
const emitter = new EventEmitter();
const clients = new Set();
+ const binaryStore = new BinaryStore();
+
+ const baseFunctions: BridgeServerFunctions = {
+ reportReady: (device) => {
+ emitter.emit('ready', device);
+ },
+ emitEvent: (_, data) => {
+ emitter.emit('event', data);
+ },
+ 'device.screenshot.receive': async (
+ reference: BinaryDataReference,
+ metadata: { width: number; height: number }
+ ) => {
+ const data = binaryStore.get(reference.transferId);
+ if (!data) {
+ throw new Error(
+ `Binary data for transfer ${reference.transferId} not found or expired`
+ );
+ }
+
+ // Clean up from store
+ binaryStore.delete(reference.transferId);
+
+ // Write to temp file
+ const tempFile = path.join(
+ os.tmpdir(),
+ `harness-screenshot-${randomUUID()}.png`
+ );
+ await fs.writeFile(tempFile, data);
+
+ return {
+ path: tempFile,
+ width: metadata.width,
+ height: metadata.height,
+ };
+ },
+ 'test.matchImageSnapshot': async (
+ screenshot: FileReference,
+ testPath: string,
+ options: ImageSnapshotOptions
+ ) => {
+ return await matchImageSnapshot(
+ screenshot,
+ testPath,
+ options,
+ context.platform.name
+ );
+ },
+ };
const group = createBirpcGroup(
- {
- reportReady: (device) => {
- emitter.emit('ready', device);
- },
- emitEvent: (_, data) => {
- emitter.emit('event', data);
- },
- } satisfies BridgeServerFunctions,
+ baseFunctions,
[],
{
timeout,
+ onFunctionError: (error, functionName, args) => {
+ console.error('Function error', error, functionName, args);
+ throw error;
+ },
onTimeoutError(functionName, args) {
throw new DeviceNotRespondingError(functionName, args);
},
@@ -84,10 +144,23 @@ export const getBridgeServer = async ({
channels.push({
post: (data) => ws.send(data),
on: (handler) => {
- ws.on('message', (event: Buffer | ArrayBuffer | Buffer[]) => {
- const message = event.toString();
- handler(message);
- });
+ ws.on(
+ 'message',
+ (event: Buffer | ArrayBuffer | Buffer[], isBinary: boolean) => {
+ if (isBinary) {
+ const uint8Array = new Uint8Array(event as any);
+ try {
+ const { transferId, data } = parseBinaryFrame(uint8Array);
+ binaryStore.add(transferId, data);
+ return;
+ } catch (error) {
+ logger.warn('Failed to parse binary frame', error);
+ }
+ }
+ const message = event.toString();
+ handler(message);
+ }
+ );
},
serialize,
deserialize,
@@ -98,6 +171,7 @@ export const getBridgeServer = async ({
const dispose = () => {
wss.close();
emitter.removeAllListeners();
+ binaryStore.dispose();
};
return {
diff --git a/packages/bridge/src/shared.ts b/packages/bridge/src/shared.ts
index 86ed98a..d5695c8 100644
--- a/packages/bridge/src/shared.ts
+++ b/packages/bridge/src/shared.ts
@@ -4,6 +4,73 @@ import type {
} from './shared/test-runner.js';
import type { TestCollectorEvents } from './shared/test-collector.js';
import type { BundlerEvents } from './shared/bundler.js';
+import type { HarnessPlatform } from '@react-native-harness/platforms';
+
+export type FileReference = {
+ path: string;
+};
+
+export type ImageSnapshotOptions = {
+ /**
+ * The name of the snapshot. This is required and must be unique within the test.
+ */
+ name: string;
+ /**
+ * Comparison algorithm to use.
+ * @default 'pixelmatch'
+ */
+ comparisonMethod?: 'pixelmatch' | 'ssim';
+ /**
+ * Matching threshold for pixelmatch, ranges from 0 to 1. Smaller values make the comparison more sensitive.
+ * @default 0.1
+ */
+ threshold?: number;
+ /**
+ * Threshold for test failure.
+ */
+ failureThreshold?: number;
+ /**
+ * Type of failure threshold.
+ * @default 'pixel'
+ */
+ failureThresholdType?: 'pixel' | 'percent';
+ /**
+ * Minimum similarity score for SSIM comparison (0-1).
+ * @default 0.95
+ */
+ ssimThreshold?: number;
+ /**
+ * Regions to ignore during comparison.
+ */
+ ignoreRegions?: Array<{
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+ }>;
+ /**
+ * If true, disables detecting and ignoring anti-aliased pixels.
+ * @default false
+ */
+ includeAA?: boolean;
+ /**
+ * Blending factor of unchanged pixels in the diff output.
+ * Ranges from 0 for pure white to 1 for original brightness
+ * @default 0.1
+ */
+ alpha?: number;
+ /**
+ * The color of differing pixels in the diff output.
+ * @default [255, 0, 0]
+ */
+ diffColor?: [number, number, number];
+ /**
+ * An alternative color to use for dark on light differences to differentiate between "added" and "removed" parts.
+ * If not provided, all differing pixels use the color specified by `diffColor`.
+ * @default null
+ */
+ diffColorAlt?: [number, number, number];
+};
export type {
TestCollectorEvents,
@@ -36,7 +103,6 @@ export type {
SetupFileBundlingFailedEvent,
BundlerEvents,
} from './shared/bundler.js';
-export { DeviceNotRespondingError } from './errors.js';
export type DeviceDescriptor = {
platform: 'ios' | 'android' | 'vega';
@@ -60,19 +126,43 @@ export type TestExecutionOptions = {
testNamePattern?: string;
setupFiles?: string[];
setupFilesAfterEnv?: string[];
+ runner: string;
};
export type BridgeClientFunctions = {
runTests: (
path: string,
- options?: TestExecutionOptions
+ options: TestExecutionOptions
) => Promise;
};
+export type BinaryDataReference = {
+ type: 'binary';
+ transferId: number;
+ size: number;
+ mimeType: 'image/png';
+};
+
+export type ScreenshotData = BinaryDataReference;
+
export type BridgeServerFunctions = {
reportReady: (device: DeviceDescriptor) => void;
emitEvent: (
event: TEvent['type'],
data: TEvent
) => void;
+ 'device.screenshot.receive': (
+ reference: BinaryDataReference,
+ metadata: { width: number; height: number }
+ ) => Promise;
+ 'test.matchImageSnapshot': (
+ screenshot: FileReference,
+ testPath: string,
+ options: ImageSnapshotOptions,
+ runner: string
+ ) => Promise<{ pass: boolean; message: string }>;
+};
+
+export type HarnessContext = {
+ platform: HarnessPlatform;
};
diff --git a/packages/bridge/tsconfig.json b/packages/bridge/tsconfig.json
index a97d426..56b5cd9 100644
--- a/packages/bridge/tsconfig.json
+++ b/packages/bridge/tsconfig.json
@@ -6,6 +6,9 @@
{
"path": "../tools"
},
+ {
+ "path": "../platforms"
+ },
{
"path": "./tsconfig.lib.json"
}
diff --git a/packages/bridge/tsconfig.lib.json b/packages/bridge/tsconfig.lib.json
index 66282ce..88461d0 100644
--- a/packages/bridge/tsconfig.lib.json
+++ b/packages/bridge/tsconfig.lib.json
@@ -14,6 +14,9 @@
"references": [
{
"path": "../tools/tsconfig.lib.json"
+ },
+ {
+ "path": "../platforms/tsconfig.lib.json"
}
]
}
diff --git a/packages/config/src/types.ts b/packages/config/src/types.ts
index 66937c6..e0580cc 100644
--- a/packages/config/src/types.ts
+++ b/packages/config/src/types.ts
@@ -1,12 +1,24 @@
import { z } from 'zod';
+const RunnerSchema = z.object({
+ name: z
+ .string()
+ .min(1, 'Runner name is required')
+ .regex(
+ /^[a-zA-Z0-9._-]+$/,
+ 'Runner name can only contain alphanumeric characters, dots, underscores, and hyphens'
+ ),
+ config: z.record(z.any()),
+ runner: z.string(),
+});
+
export const ConfigSchema = z
.object({
entryPoint: z.string().min(1, 'Entry point is required'),
appRegistryComponentName: z
.string()
.min(1, 'App registry component name is required'),
- runners: z.array(z.any()).min(1, 'At least one runner is required'),
+ runners: z.array(RunnerSchema).min(1, 'At least one runner is required'),
defaultRunner: z.string().optional(),
webSocketPort: z.number().optional().default(3001),
bridgeTimeout: z
diff --git a/packages/github-action/src/android/action.yml b/packages/github-action/src/android/action.yml
index 820df9e..5c4e5a4 100644
--- a/packages/github-action/src/android/action.yml
+++ b/packages/github-action/src/android/action.yml
@@ -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:
@@ -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
diff --git a/packages/github-action/src/ios/action.yml b/packages/github-action/src/ios/action.yml
index 173c923..706d573 100644
--- a/packages/github-action/src/ios/action.yml
+++ b/packages/github-action/src/ios/action.yml
@@ -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:
@@ -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
diff --git a/packages/jest/src/harness.ts b/packages/jest/src/harness.ts
index 536516a..e1fd62b 100644
--- a/packages/jest/src/harness.ts
+++ b/packages/jest/src/harness.ts
@@ -2,7 +2,11 @@ import {
getBridgeServer,
BridgeServer,
} from '@react-native-harness/bridge/server';
-import { BridgeClientFunctions } from '@react-native-harness/bridge';
+import {
+ HarnessContext,
+ TestExecutionOptions,
+ TestSuiteResult,
+} from '@react-native-harness/bridge';
import {
HarnessPlatform,
HarnessPlatformRunner,
@@ -16,8 +20,14 @@ import { InitializationTimeoutError, MaxAppRestartsError } from './errors.js';
import { Config as HarnessConfig } from '@react-native-harness/config';
import { createCrashMonitor, CrashMonitor } from './crash-monitor.js';
+export type HarnessRunTestsOptions = Exclude;
+
export type Harness = {
- runTests: BridgeClientFunctions['runTests'];
+ context: HarnessContext;
+ runTests: (
+ path: string,
+ options: HarnessRunTestsOptions
+ ) => Promise;
restart: () => Promise;
dispose: () => Promise;
crashMonitor: CrashMonitor;
@@ -118,6 +128,10 @@ const getHarnessInternal = async (
projectRoot: string,
signal: AbortSignal
): Promise => {
+ const context: HarnessContext = {
+ platform,
+ };
+
const [metroInstance, platformInstance, serverBridge] = await Promise.all([
getMetroInstance({ projectRoot, harnessConfig: config }, signal),
import(platform.runner).then((module) =>
@@ -126,6 +140,7 @@ const getHarnessInternal = async (
getBridgeServer({
port: config.webSocketPort,
timeout: config.bridgeTimeout,
+ context,
}),
]);
@@ -174,6 +189,7 @@ const getHarnessInternal = async (
});
return {
+ context,
runTests: async (path, options) => {
const client = serverBridge.rpc.clients.at(-1);
@@ -181,7 +197,10 @@ const getHarnessInternal = async (
throw new Error('No client found');
}
- return await client.runTests(path, options);
+ return await client.runTests(path, {
+ ...options,
+ runner: platform.runner,
+ });
},
restart,
dispose,
diff --git a/packages/jest/src/index.ts b/packages/jest/src/index.ts
index aeae142..b087d39 100644
--- a/packages/jest/src/index.ts
+++ b/packages/jest/src/index.ts
@@ -16,7 +16,7 @@ import { setup } from './setup.js';
import { teardown } from './teardown.js';
import { HarnessError } from '@react-native-harness/tools';
import { getErrorMessage } from './logs.js';
-import { DeviceNotRespondingError } from '@react-native-harness/bridge';
+import { DeviceNotRespondingError } from '@react-native-harness/bridge/server';
import { NativeCrashError } from './errors.js';
class CancelRun extends Error {
diff --git a/packages/jest/src/run.ts b/packages/jest/src/run.ts
index 15f3d1d..8416827 100644
--- a/packages/jest/src/run.ts
+++ b/packages/jest/src/run.ts
@@ -88,6 +88,7 @@ export const runHarnessTestFile: RunHarnessTestFile = async ({
testNamePattern: globalConfig.testNamePattern,
setupFiles,
setupFilesAfterEnv,
+ runner: harness.context.platform.runner,
});
const end = Date.now();
diff --git a/packages/jest/tsconfig.json b/packages/jest/tsconfig.json
index d3866d2..e0031d8 100644
--- a/packages/jest/tsconfig.json
+++ b/packages/jest/tsconfig.json
@@ -6,9 +6,6 @@
{
"path": "../cli"
},
- {
- "path": "../bundler-metro"
- },
{
"path": "../tools"
},
@@ -18,6 +15,9 @@
{
"path": "../config"
},
+ {
+ "path": "../bundler-metro"
+ },
{
"path": "../bridge"
},
diff --git a/packages/platform-ios/src/instance.ts b/packages/platform-ios/src/instance.ts
index 46711b2..6e4098f 100644
--- a/packages/platform-ios/src/instance.ts
+++ b/packages/platform-ios/src/instance.ts
@@ -41,15 +41,6 @@ export const getAppleSimulatorPlatformInstance = async (
throw new Error('Simulator is not booted');
}
- const isAvailable = await simctl.isAppInstalled(udid, config.bundleId);
-
- if (!isAvailable) {
- throw new AppNotInstalledError(
- config.bundleId,
- getDeviceName(config.device)
- );
- }
-
return {
startApp: async () => {
await simctl.startApp(udid, config.bundleId);
diff --git a/packages/platform-ios/src/utils.ts b/packages/platform-ios/src/utils.ts
index 4bd591f..0a24e06 100644
--- a/packages/platform-ios/src/utils.ts
+++ b/packages/platform-ios/src/utils.ts
@@ -1,5 +1,4 @@
import { isAppleDeviceSimulator, type AppleDevice } from './config.js';
-
export const getDeviceName = (device: AppleDevice): string => {
if (isAppleDeviceSimulator(device)) {
return `${device.name} (${device.systemVersion}) (simulator)`;
diff --git a/packages/platform-ios/src/xcrun/simctl.ts b/packages/platform-ios/src/xcrun/simctl.ts
index 71588c7..df532d7 100644
--- a/packages/platform-ios/src/xcrun/simctl.ts
+++ b/packages/platform-ios/src/xcrun/simctl.ts
@@ -138,3 +138,11 @@ export const isAppRunning = async (
return false;
}
};
+
+export const screenshot = async (
+ udid: string,
+ destination: string
+): Promise => {
+ await spawn('xcrun', ['simctl', 'io', udid, 'screenshot', destination]);
+ return destination;
+};
diff --git a/packages/platform-ios/tsconfig.tsbuildinfo b/packages/platform-ios/tsconfig.tsbuildinfo
new file mode 100644
index 0000000..7cf2f68
--- /dev/null
+++ b/packages/platform-ios/tsconfig.tsbuildinfo
@@ -0,0 +1 @@
+{"fileNames":[],"fileInfos":[],"root":[],"options":{"composite":true,"declarationMap":true,"emitDeclarationOnly":true,"importHelpers":true,"module":199,"noEmitOnError":true,"noFallthroughCasesInSwitch":true,"noImplicitOverride":true,"noImplicitReturns":true,"noUnusedLocals":true,"skipLibCheck":true,"strict":true,"target":9},"version":"5.9.3"}
\ No newline at end of file
diff --git a/packages/platforms/src/errors.ts b/packages/platforms/src/errors.ts
index 9461fb2..8a4c21e 100644
--- a/packages/platforms/src/errors.ts
+++ b/packages/platforms/src/errors.ts
@@ -14,3 +14,17 @@ export class DeviceNotFoundError extends Error {
this.name = 'DeviceNotFoundError';
}
}
+
+export class DependencyNotFoundError extends Error {
+ constructor(
+ public readonly dependencyName: string,
+ public readonly installInstructions?: string
+ ) {
+ super(
+ `Dependency "${dependencyName}" not found.${
+ installInstructions ? ` ${installInstructions}` : ''
+ }`
+ );
+ this.name = 'DependencyNotFoundError';
+ }
+}
\ No newline at end of file
diff --git a/packages/platforms/src/index.ts b/packages/platforms/src/index.ts
index 79197e8..6e08ee4 100644
--- a/packages/platforms/src/index.ts
+++ b/packages/platforms/src/index.ts
@@ -1,2 +1,6 @@
export type { HarnessPlatform, HarnessPlatformRunner } from './types.js';
-export { AppNotInstalledError, DeviceNotFoundError } from './errors.js';
+export {
+ AppNotInstalledError,
+ DeviceNotFoundError,
+ DependencyNotFoundError,
+} from './errors.js';
diff --git a/packages/runtime/package.json b/packages/runtime/package.json
index ec10959..77277ff 100644
--- a/packages/runtime/package.json
+++ b/packages/runtime/package.json
@@ -1,10 +1,19 @@
{
"name": "@react-native-harness/runtime",
+ "description": "The core test runtime that executes on React Native devices, providing Jest-compatible APIs (describe, it, expect) and managing test collection, execution, and result reporting in native environments.",
"version": "1.0.0-alpha.21",
"type": "module",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
+ "files": [
+ "src",
+ "lib",
+ "!**/__tests__",
+ "!**/__fixtures__",
+ "!**/__mocks__",
+ "!**/.*"
+ ],
"exports": {
"./package.json": "./package.json",
".": {
@@ -42,5 +51,14 @@
"react": "*",
"react-native": "*"
},
+ "author": {
+ "name": "Szymon Chmal",
+ "email": "szymon.chmal@callstack.com"
+ },
+ "homepage": "https://github.com/callstackincubator/react-native-harness",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/callstackincubator/react-native-harness.git"
+ },
"license": "MIT"
}
diff --git a/packages/runtime/src/__tests__/expect.test.ts b/packages/runtime/src/__tests__/expect.test.ts
deleted file mode 100644
index dd06f85..0000000
--- a/packages/runtime/src/__tests__/expect.test.ts
+++ /dev/null
@@ -1,627 +0,0 @@
-import { describe, test } from 'vitest';
-import { expect } from '../expect/index.js';
-
-describe('expect - Basic Matchers', () => {
- describe('toBe', () => {
- test('should pass for identical primitive values', () => {
- expect(1).toBe(1);
- expect('hello').toBe('hello');
- expect(true).toBe(true);
- expect(null).toBe(null);
- expect(undefined).toBe(undefined);
- });
-
- test('should fail for different primitive values', () => {
- expect(() => expect(1).toBe(2)).toThrow();
- expect(() => expect('hello').toBe('world')).toThrow();
- expect(() => expect(true).toBe(false)).toThrow();
- });
-
- test('should fail for objects with same content', () => {
- expect(() => expect({}).toBe({})).toThrow();
- expect(() => expect([]).toBe([])).toThrow();
- });
- });
-
- describe('toEqual', () => {
- test('should pass for equal primitive values', () => {
- expect(1).toEqual(1);
- expect('hello').toEqual('hello');
- expect(true).toEqual(true);
- });
-
- test('should pass for equal objects', () => {
- expect({ a: 1, b: 2 }).toEqual({ a: 1, b: 2 });
- expect([1, 2, 3]).toEqual([1, 2, 3]);
- expect({ nested: { value: 'test' } }).toEqual({
- nested: { value: 'test' },
- });
- });
-
- test('should fail for unequal objects', () => {
- expect(() => expect({ a: 1 }).toEqual({ a: 2 })).toThrow();
- expect(() => expect([1, 2]).toEqual([1, 3])).toThrow();
- });
- });
-
- describe('toStrictEqual', () => {
- test('should pass for strictly equal values', () => {
- expect({ a: 1 }).toStrictEqual({ a: 1 });
- expect([1, 2, 3]).toStrictEqual([1, 2, 3]);
- });
-
- test('should fail for objects with undefined vs missing properties', () => {
- expect(() =>
- expect({ a: 1, b: undefined }).toStrictEqual({ a: 1 })
- ).toThrow();
- });
-
- test('should differentiate between sparse and dense arrays', () => {
- const sparse = [1, , 3]; // eslint-disable-line no-sparse-arrays
- const dense = [1, undefined, 3];
- expect(() => expect(sparse).toStrictEqual(dense)).toThrow();
- });
- });
-});
-
-describe('expect - Truthiness', () => {
- describe('toBeTruthy', () => {
- test('should pass for truthy values', () => {
- expect(true).toBeTruthy();
- expect(1).toBeTruthy();
- expect('hello').toBeTruthy();
- expect({}).toBeTruthy();
- expect([]).toBeTruthy();
- expect(function () {
- // noop
- }).toBeTruthy();
- });
-
- test('should fail for falsy values', () => {
- expect(() => expect(false).toBeTruthy()).toThrow();
- expect(() => expect(0).toBeTruthy()).toThrow();
- expect(() => expect('').toBeTruthy()).toThrow();
- expect(() => expect(null).toBeTruthy()).toThrow();
- expect(() => expect(undefined).toBeTruthy()).toThrow();
- expect(() => expect(NaN).toBeTruthy()).toThrow();
- });
- });
-
- describe('toBeFalsy', () => {
- test('should pass for falsy values', () => {
- expect(false).toBeFalsy();
- expect(0).toBeFalsy();
- expect('').toBeFalsy();
- expect(null).toBeFalsy();
- expect(undefined).toBeFalsy();
- expect(NaN).toBeFalsy();
- });
-
- test('should fail for truthy values', () => {
- expect(() => expect(true).toBeFalsy()).toThrow();
- expect(() => expect(1).toBeFalsy()).toThrow();
- expect(() => expect('hello').toBeFalsy()).toThrow();
- });
- });
-});
-
-describe('expect - Numbers', () => {
- describe('toBeGreaterThan', () => {
- test('should pass when value is greater', () => {
- expect(5).toBeGreaterThan(3);
- expect(0).toBeGreaterThan(-1);
- expect(1.5).toBeGreaterThan(1.4);
- });
-
- test('should fail when value is equal or less', () => {
- expect(() => expect(3).toBeGreaterThan(5)).toThrow();
- expect(() => expect(3).toBeGreaterThan(3)).toThrow();
- });
- });
-
- describe('toBeGreaterThanOrEqual', () => {
- test('should pass when value is greater or equal', () => {
- expect(5).toBeGreaterThanOrEqual(3);
- expect(3).toBeGreaterThanOrEqual(3);
- expect(0).toBeGreaterThanOrEqual(-1);
- });
-
- test('should fail when value is less', () => {
- expect(() => expect(3).toBeGreaterThanOrEqual(5)).toThrow();
- });
- });
-
- describe('toBeLessThan', () => {
- test('should pass when value is less', () => {
- expect(3).toBeLessThan(5);
- expect(-1).toBeLessThan(0);
- expect(1.4).toBeLessThan(1.5);
- });
-
- test('should fail when value is equal or greater', () => {
- expect(() => expect(5).toBeLessThan(3)).toThrow();
- expect(() => expect(3).toBeLessThan(3)).toThrow();
- });
- });
-
- describe('toBeLessThanOrEqual', () => {
- test('should pass when value is less or equal', () => {
- expect(3).toBeLessThanOrEqual(5);
- expect(3).toBeLessThanOrEqual(3);
- expect(-1).toBeLessThanOrEqual(0);
- });
-
- test('should fail when value is greater', () => {
- expect(() => expect(5).toBeLessThanOrEqual(3)).toThrow();
- });
- });
-
- describe('toBeCloseTo', () => {
- test('should pass for values close to each other', () => {
- expect(0.2 + 0.1).toBeCloseTo(0.3);
- expect(0.2 + 0.1).toBeCloseTo(0.3, 5);
- });
-
- test('should fail for values far apart', () => {
- expect(() => expect(0.1).toBeCloseTo(0.2)).toThrow();
- });
-
- test('should respect precision parameter', () => {
- expect(1.23456).toBeCloseTo(1.23, 2);
- expect(() => expect(1.23456).toBeCloseTo(1.23, 4)).toThrow();
- });
- });
-
- describe('toBeNaN', () => {
- test('should pass for NaN', () => {
- expect(NaN).toBeNaN();
- expect(Number('not a number')).toBeNaN();
- expect(0 / 0).toBeNaN();
- });
-
- test('should fail for numbers', () => {
- expect(() => expect(1).toBeNaN()).toThrow();
- expect(() => expect(0).toBeNaN()).toThrow();
- expect(() => expect(Infinity).toBeNaN()).toThrow();
- });
- });
-});
-
-describe('expect - Strings', () => {
- describe('toMatch', () => {
- test('should pass for matching strings', () => {
- expect('hello world').toMatch('world');
- expect('hello world').toMatch(/world/);
- expect('hello world').toMatch(/^hello/);
- });
-
- test('should fail for non-matching strings', () => {
- expect(() => expect('hello world').toMatch('xyz')).toThrow();
- expect(() => expect('hello world').toMatch(/xyz/)).toThrow();
- });
- });
-
- describe('toContain', () => {
- test('should pass for strings containing substring', () => {
- expect('hello world').toContain('world');
- expect('hello world').toContain('hello');
- expect('hello world').toContain(' ');
- });
-
- test('should fail for strings not containing substring', () => {
- expect(() => expect('hello world').toContain('xyz')).toThrow();
- });
- });
-
- describe('toHaveLength', () => {
- test('should pass for correct string length', () => {
- expect('hello').toHaveLength(5);
- expect('').toHaveLength(0);
- });
-
- test('should fail for incorrect string length', () => {
- expect(() => expect('hello').toHaveLength(3)).toThrow();
- });
- });
-});
-
-describe('expect - Arrays and Iterables', () => {
- describe('toContain for arrays', () => {
- test('should pass for arrays containing value', () => {
- expect([1, 2, 3]).toContain(2);
- expect(['a', 'b', 'c']).toContain('b');
- });
-
- test('should fail for arrays not containing value', () => {
- expect(() => expect([1, 2, 3]).toContain(4)).toThrow();
- });
- });
-
- describe('toContainEqual', () => {
- test('should pass for arrays containing equal object', () => {
- expect([{ id: 1 }, { id: 2 }]).toContainEqual({ id: 1 });
- expect([
- [1, 2],
- [3, 4],
- ]).toContainEqual([1, 2]);
- });
-
- test('should fail for arrays not containing equal object', () => {
- expect(() => expect([{ id: 1 }]).toContainEqual({ id: 2 })).toThrow();
- });
- });
-
- describe('toHaveLength for arrays', () => {
- test('should pass for correct array length', () => {
- expect([1, 2, 3]).toHaveLength(3);
- expect([]).toHaveLength(0);
- });
-
- test('should fail for incorrect array length', () => {
- expect(() => expect([1, 2, 3]).toHaveLength(2)).toThrow();
- });
- });
-});
-
-describe('expect - Objects', () => {
- describe('toHaveProperty', () => {
- test('should pass for existing properties', () => {
- const obj = { a: 1, b: { c: 2 } };
- expect(obj).toHaveProperty('a');
- expect(obj).toHaveProperty('b.c');
- expect(obj).toHaveProperty(['b', 'c']);
- });
-
- test('should pass for existing properties with values', () => {
- const obj = { a: 1, b: { c: 2 } };
- expect(obj).toHaveProperty('a', 1);
- expect(obj).toHaveProperty('b.c', 2);
- });
-
- test('should fail for non-existing properties', () => {
- const obj = { a: 1 };
- expect(() => expect(obj).toHaveProperty('b')).toThrow();
- expect(() => expect(obj).toHaveProperty('a.b')).toThrow();
- });
-
- test('should fail for wrong property values', () => {
- const obj = { a: 1 };
- expect(() => expect(obj).toHaveProperty('a', 2)).toThrow();
- });
- });
-
- describe('toMatchObject', () => {
- test('should pass for matching object subset', () => {
- const obj = { a: 1, b: 2, c: 3 };
- expect(obj).toMatchObject({ a: 1, b: 2 });
- expect(obj).toMatchObject({ a: 1 });
- });
-
- test('should pass for nested object matching', () => {
- const obj = { a: { b: { c: 1 } }, d: 2 };
- expect(obj).toMatchObject({ a: { b: { c: 1 } } });
- });
-
- test('should fail for non-matching properties', () => {
- const obj = { a: 1, b: 2 };
- expect(() => expect(obj).toMatchObject({ a: 2 })).toThrow();
- expect(() => expect(obj).toMatchObject({ c: 3 })).toThrow();
- });
- });
-});
-
-describe('expect - Type Checking', () => {
- describe('toBeInstanceOf', () => {
- test('should pass for correct instances', () => {
- expect(new Date()).toBeInstanceOf(Date);
- expect([]).toBeInstanceOf(Array);
- expect(new Error()).toBeInstanceOf(Error);
- expect(/regex/).toBeInstanceOf(RegExp);
- });
-
- test('should fail for incorrect instances', () => {
- expect(() => expect('string').toBeInstanceOf(Date)).toThrow();
- expect(() => expect(123).toBeInstanceOf(String)).toThrow();
- });
- });
-
- describe('toBeTypeOf', () => {
- test('should pass for correct types', () => {
- expect('hello').toBeTypeOf('string');
- expect(123).toBeTypeOf('number');
- expect(true).toBeTypeOf('boolean');
- expect(undefined).toBeTypeOf('undefined');
- expect(Symbol('test')).toBeTypeOf('symbol');
- expect(() => {
- // noop
- }).toBeTypeOf('function');
- expect({}).toBeTypeOf('object');
- });
-
- test('should fail for incorrect types', () => {
- expect(() => expect('hello').toBeTypeOf('number')).toThrow();
- expect(() => expect(123).toBeTypeOf('string')).toThrow();
- });
- });
-
- describe('toBeDefined', () => {
- test('should pass for defined values', () => {
- expect(0).toBeDefined();
- expect('').toBeDefined();
- expect(false).toBeDefined();
- expect(null).toBeDefined();
- expect({}).toBeDefined();
- });
-
- test('should fail for undefined', () => {
- expect(() => expect(undefined).toBeDefined()).toThrow();
- });
- });
-
- describe('toBeUndefined', () => {
- test('should pass for undefined', () => {
- expect(undefined).toBeUndefined();
- let uninitialized;
- expect(uninitialized).toBeUndefined();
- });
-
- test('should fail for defined values', () => {
- expect(() => expect(null).toBeUndefined()).toThrow();
- expect(() => expect(0).toBeUndefined()).toThrow();
- });
- });
-
- describe('toBeNull', () => {
- test('should pass for null', () => {
- expect(null).toBeNull();
- });
-
- test('should fail for non-null values', () => {
- expect(() => expect(undefined).toBeNull()).toThrow();
- expect(() => expect(0).toBeNull()).toThrow();
- });
- });
-});
-
-describe('expect - Exceptions', () => {
- describe('toThrow', () => {
- test('should pass for functions that throw', () => {
- expect(() => {
- throw new Error('test error');
- }).toThrow();
-
- expect(() => {
- throw new Error('test error');
- }).toThrow('test error');
-
- expect(() => {
- throw new Error('test error');
- }).toThrow(/test/);
- });
-
- test('should pass for specific error types', () => {
- expect(() => {
- throw new TypeError('type error');
- }).toThrow(TypeError);
-
- expect(() => {
- throw new RangeError('range error');
- }).toThrow(RangeError);
- });
-
- test('should fail for functions that do not throw', () => {
- expect(() =>
- expect(() => {
- // noop
- }).toThrow()
- ).toThrow();
- });
-
- test('should fail for wrong error message', () => {
- expect(() => {
- expect(() => {
- throw new Error('actual message');
- }).toThrow('expected message');
- }).toThrow();
- });
-
- test('should fail for wrong error type', () => {
- expect(() => {
- expect(() => {
- throw new Error('test');
- }).toThrow(TypeError);
- }).toThrow();
- });
- });
-
- describe('toThrowError', () => {
- test('should be alias for toThrow', () => {
- expect(() => {
- throw new Error('test');
- }).toThrowError();
-
- expect(() => {
- throw new Error('test');
- }).toThrowError('test');
- });
- });
-});
-
-describe('expect - Asymmetric Matchers', () => {
- describe('expect.any', () => {
- test('should match any instance of constructor', () => {
- expect('hello').toEqual(expect.any(String));
- expect(123).toEqual(expect.any(Number));
- expect({}).toEqual(expect.any(Object));
- expect([]).toEqual(expect.any(Array));
- expect(new Date()).toEqual(expect.any(Date));
- });
-
- test('should work in object matching', () => {
- expect({ id: 1, name: 'test' }).toEqual({
- id: expect.any(Number),
- name: expect.any(String),
- });
- });
- });
-
- describe('expect.anything', () => {
- test('should match any defined value', () => {
- expect('hello').toEqual(expect.anything());
- expect(123).toEqual(expect.anything());
- expect({}).toEqual(expect.anything());
- expect([]).toEqual(expect.anything());
- });
-
- test('should not match undefined', () => {
- expect(() => expect(undefined).toEqual(expect.anything())).toThrow();
- });
- });
-
- describe('expect.arrayContaining', () => {
- test('should match arrays containing all specified elements', () => {
- expect([1, 2, 3, 4]).toEqual(expect.arrayContaining([2, 3]));
- expect(['a', 'b', 'c']).toEqual(expect.arrayContaining(['b']));
- });
-
- test('should fail when array does not contain all elements', () => {
- expect(() =>
- expect([1, 2, 3]).toEqual(expect.arrayContaining([4, 5]))
- ).toThrow();
- });
- });
-
- describe('expect.objectContaining', () => {
- test('should match objects containing specified properties', () => {
- expect({ a: 1, b: 2, c: 3 }).toEqual(
- expect.objectContaining({ a: 1, b: 2 })
- );
- expect({ name: 'test', id: 1 }).toEqual(
- expect.objectContaining({ name: 'test' })
- );
- });
-
- test('should fail when object does not contain specified properties', () => {
- expect(() =>
- expect({ a: 1 }).toEqual(expect.objectContaining({ b: 2 }))
- ).toThrow();
- });
- });
-
- describe('expect.stringContaining', () => {
- test('should match strings containing substring', () => {
- expect('hello world').toEqual(expect.stringContaining('world'));
- expect('hello world').toEqual(expect.stringContaining('hello'));
- });
-
- test('should fail when string does not contain substring', () => {
- expect(() =>
- expect('hello world').toEqual(expect.stringContaining('xyz'))
- ).toThrow();
- });
- });
-
- describe('expect.stringMatching', () => {
- test('should match strings matching regex', () => {
- expect('hello world').toEqual(expect.stringMatching(/world/));
- expect('hello world').toEqual(expect.stringMatching('world'));
- });
-
- test('should fail when string does not match pattern', () => {
- expect(() =>
- expect('hello world').toEqual(expect.stringMatching(/xyz/))
- ).toThrow();
- });
- });
-});
-
-describe('expect - Negation', () => {
- test('should negate basic matchers with .not', () => {
- expect(1).not.toBe(2);
- expect('hello').not.toEqual('world');
- expect([1, 2, 3]).not.toContain(4);
- expect({ a: 1 }).not.toHaveProperty('b');
- });
-
- test('should negate truthiness', () => {
- expect(false).not.toBeTruthy();
- expect(true).not.toBeFalsy();
- expect(0).not.toBeTruthy();
- });
-
- test('should negate type checks', () => {
- expect('string').not.toBeTypeOf('number');
- expect(undefined).not.toBeDefined();
- expect(123).not.toBeUndefined();
- });
-});
-
-describe('expect - Custom Messages', () => {
- test('should support custom error messages', () => {
- expect(() => {
- expect(1, 'This is a custom message').toBe(2);
- }).toThrow('This is a custom message');
- });
-});
-
-describe('expect - Edge Cases', () => {
- test('should handle circular references', () => {
- const circular: Record = { a: 1 };
- circular.self = circular;
-
- const circular2: Record = { a: 1 };
- circular2.self = circular2;
-
- expect(circular).toEqual(circular2);
- });
-
- test('should handle sparse arrays', () => {
- const sparse1 = [1, , 3]; // eslint-disable-line no-sparse-arrays
- const sparse2 = [1, , 3]; // eslint-disable-line no-sparse-arrays
- expect(sparse1).toEqual(sparse2);
- });
-
- test('should handle special number values', () => {
- expect(Infinity).toBe(Infinity);
- expect(-Infinity).toBe(-Infinity);
- expect(0).toBe(0);
- expect(-0).toBe(-0);
- });
-
- test('should handle Date objects', () => {
- const date1 = new Date('2023-01-01');
- const date2 = new Date('2023-01-01');
- expect(date1).toEqual(date2);
- expect(date1).not.toBe(date2);
- });
-
- test('should handle RegExp objects', () => {
- expect(/abc/g).toEqual(/abc/g);
- expect(/abc/g).not.toBe(/abc/g);
- });
-
- test('should handle Symbol primitives', () => {
- const sym1 = Symbol('test');
- const sym2 = Symbol('test');
- expect(sym1).toBe(sym1);
- expect(sym1).not.toBe(sym2);
- });
-
- test('should handle Map objects', () => {
- const map1 = new Map([
- ['a', 1],
- ['b', 2],
- ]);
- const map2 = new Map([
- ['a', 1],
- ['b', 2],
- ]);
- expect(map1).toEqual(map2);
- });
-
- test('should handle Set objects', () => {
- const set1 = new Set([1, 2, 3]);
- const set2 = new Set([1, 2, 3]);
- expect(set1).toEqual(set2);
- });
-});
diff --git a/packages/runtime/src/client/factory.ts b/packages/runtime/src/client/factory.ts
index 15d5e8b..20726ac 100644
--- a/packages/runtime/src/client/factory.ts
+++ b/packages/runtime/src/client/factory.ts
@@ -14,6 +14,7 @@ import { getBundler, evaluateModule, Bundler } from '../bundler/index.js';
import { markTestsAsSkippedByName } from '../filtering/index.js';
import { setup } from '../render/setup.js';
import { runSetupFiles } from './setup-files.js';
+import { setClient } from './store.js';
export const getClient = async () => {
const client = await getBridgeClient(getWSServer(), {
@@ -22,9 +23,11 @@ export const getClient = async () => {
},
});
+ setClient(client);
+
client.rpc.$functions.runTests = async (
path: string,
- options: TestExecutionOptions = {}
+ options: TestExecutionOptions
) => {
if (store.getState().status === 'running') {
throw new Error('Already running tests');
@@ -84,7 +87,11 @@ export const getClient = async () => {
)
: collectionResult.testSuite;
- const result = await runner.run(processedTestSuite, path);
+ const result = await runner.run({
+ testSuite: processedTestSuite,
+ testFilePath: path,
+ runner: options.runner,
+ });
return result;
} finally {
collector?.dispose();
diff --git a/packages/runtime/src/client/store.ts b/packages/runtime/src/client/store.ts
new file mode 100644
index 0000000..d003793
--- /dev/null
+++ b/packages/runtime/src/client/store.ts
@@ -0,0 +1,16 @@
+import type { BridgeClient } from '@react-native-harness/bridge/client';
+
+let clientInstance: BridgeClient | null = null;
+
+export const setClient = (client: BridgeClient): void => {
+ clientInstance = client;
+};
+
+export const getClientInstance = (): BridgeClient => {
+ if (!clientInstance) {
+ throw new Error(
+ 'Bridge client not initialized. This should not happen in normal operation.'
+ );
+ }
+ return clientInstance;
+};
diff --git a/packages/runtime/src/expect/expect.ts b/packages/runtime/src/expect/expect.ts
new file mode 100644
index 0000000..beca251
--- /dev/null
+++ b/packages/runtime/src/expect/expect.ts
@@ -0,0 +1,127 @@
+// This is adapted version of https://github.com/vitest-dev/vitest/blob/main/packages/vitest/src/integrations/chai/index.ts
+// Credits to Vitest team for the original implementation.
+
+import type { Assertion, ExpectStatic, MatcherState } from '@vitest/expect';
+import {
+ addCustomEqualityTesters,
+ ASYMMETRIC_MATCHERS_OBJECT,
+ customMatchers,
+ getState,
+ GLOBAL_EXPECT,
+ setState,
+} from '@vitest/expect';
+import * as chai from 'chai';
+
+// Setup additional matchers
+import './setup.js';
+import { toMatchImageSnapshot } from './matchers/toMatchImageSnapshot.js';
+
+export function createExpect(): ExpectStatic {
+ const expect = ((value: unknown, message?: string): Assertion => {
+ const { assertionCalls } = getState(expect);
+ setState({ assertionCalls: assertionCalls + 1 }, expect);
+ return chai.expect(value, message) as unknown as Assertion;
+ }) as ExpectStatic;
+ Object.assign(expect, chai.expect);
+ Object.assign(
+ expect,
+ globalThis[ASYMMETRIC_MATCHERS_OBJECT as unknown as keyof typeof globalThis]
+ );
+
+ expect.getState = () => getState(expect);
+ expect.setState = (state) => setState(state as Partial, expect);
+
+ // @ts-expect-error global is not typed
+ const globalState = getState(globalThis[GLOBAL_EXPECT]) || {};
+
+ setState(
+ {
+ // this should also add "snapshotState" that is added conditionally
+ ...globalState,
+ assertionCalls: 0,
+ isExpectingAssertions: false,
+ isExpectingAssertionsError: null,
+ expectedAssertionsNumber: null,
+ expectedAssertionsNumberErrorGen: null,
+ },
+ expect
+ );
+
+ // @ts-expect-error untyped
+ expect.extend = (matchers) => chai.expect.extend(expect, matchers);
+ // @ts-expect-error untyped
+ expect.addEqualityTesters = (customTesters) =>
+ addCustomEqualityTesters(customTesters);
+
+ // @ts-expect-error untyped
+ expect.soft = (...args) => {
+ // @ts-expect-error private soft access
+ return expect(...args).withContext({ soft: true }) as Assertion;
+ };
+
+ // @ts-expect-error untyped
+ expect.unreachable = (message?: string) => {
+ chai.assert.fail(
+ `expected${message ? ` "${message}" ` : ' '}not to be reached`
+ );
+ };
+
+ function assertions(expected: number) {
+ const errorGen = () =>
+ new Error(
+ `expected number of assertions to be ${expected}, but got ${
+ expect.getState().assertionCalls
+ }`
+ );
+ if (Error.captureStackTrace) {
+ Error.captureStackTrace(errorGen(), assertions);
+ }
+
+ expect.setState({
+ expectedAssertionsNumber: expected,
+ expectedAssertionsNumberErrorGen: errorGen,
+ });
+ }
+
+ function hasAssertions() {
+ const error = new Error('expected any number of assertion, but got none');
+ if (Error.captureStackTrace) {
+ Error.captureStackTrace(error, hasAssertions);
+ }
+
+ expect.setState({
+ isExpectingAssertions: true,
+ isExpectingAssertionsError: error,
+ });
+ }
+
+ chai.util.addMethod(expect, 'assertions', assertions);
+ chai.util.addMethod(expect, 'hasAssertions', hasAssertions);
+
+ expect.extend(customMatchers);
+ expect.extend({
+ toMatchImageSnapshot,
+ });
+
+ return expect;
+}
+
+const globalExpect: ExpectStatic = createExpect();
+
+Object.defineProperty(globalThis, GLOBAL_EXPECT, {
+ value: globalExpect,
+ writable: true,
+ configurable: true,
+});
+
+export { assert, should } from 'chai';
+export { chai, globalExpect as expect };
+
+export type {
+ Assertion,
+ AsymmetricMatchersContaining,
+ DeeplyAllowMatchers,
+ ExpectStatic,
+ JestAssertion,
+ Matchers,
+} from '@vitest/expect';
diff --git a/packages/runtime/src/expect/index.ts b/packages/runtime/src/expect/index.ts
index adccb02..4bb25dd 100644
--- a/packages/runtime/src/expect/index.ts
+++ b/packages/runtime/src/expect/index.ts
@@ -1,123 +1 @@
-// This is adapted version of https://github.com/vitest-dev/vitest/blob/main/packages/vitest/src/integrations/chai/index.ts
-// Credits to Vitest team for the original implementation.
-
-import type { Assertion, ExpectStatic, MatcherState } from '@vitest/expect';
-import {
- addCustomEqualityTesters,
- ASYMMETRIC_MATCHERS_OBJECT,
- customMatchers,
- getState,
- GLOBAL_EXPECT,
- setState,
-} from '@vitest/expect';
-import * as chai from 'chai';
-
-// Setup additional matchers
-import './setup.js';
-
-export function createExpect(): ExpectStatic {
- const expect = ((value: unknown, message?: string): Assertion => {
- const { assertionCalls } = getState(expect);
- setState({ assertionCalls: assertionCalls + 1 }, expect);
- return chai.expect(value, message) as unknown as Assertion;
- }) as ExpectStatic;
- Object.assign(expect, chai.expect);
- Object.assign(
- expect,
- globalThis[ASYMMETRIC_MATCHERS_OBJECT as unknown as keyof typeof globalThis]
- );
-
- expect.getState = () => getState(expect);
- expect.setState = (state) => setState(state as Partial, expect);
-
- // @ts-expect-error global is not typed
- const globalState = getState(globalThis[GLOBAL_EXPECT]) || {};
-
- setState(
- {
- // this should also add "snapshotState" that is added conditionally
- ...globalState,
- assertionCalls: 0,
- isExpectingAssertions: false,
- isExpectingAssertionsError: null,
- expectedAssertionsNumber: null,
- expectedAssertionsNumberErrorGen: null,
- },
- expect
- );
-
- // @ts-expect-error untyped
- expect.extend = (matchers) => chai.expect.extend(expect, matchers);
- // @ts-expect-error untyped
- expect.addEqualityTesters = (customTesters) =>
- addCustomEqualityTesters(customTesters);
-
- // @ts-expect-error untyped
- expect.soft = (...args) => {
- // @ts-expect-error private soft access
- return expect(...args).withContext({ soft: true }) as Assertion;
- };
-
- // @ts-expect-error untyped
- expect.unreachable = (message?: string) => {
- chai.assert.fail(
- `expected${message ? ` "${message}" ` : ' '}not to be reached`
- );
- };
-
- function assertions(expected: number) {
- const errorGen = () =>
- new Error(
- `expected number of assertions to be ${expected}, but got ${
- expect.getState().assertionCalls
- }`
- );
- if (Error.captureStackTrace) {
- Error.captureStackTrace(errorGen(), assertions);
- }
-
- expect.setState({
- expectedAssertionsNumber: expected,
- expectedAssertionsNumberErrorGen: errorGen,
- });
- }
-
- function hasAssertions() {
- const error = new Error('expected any number of assertion, but got none');
- if (Error.captureStackTrace) {
- Error.captureStackTrace(error, hasAssertions);
- }
-
- expect.setState({
- isExpectingAssertions: true,
- isExpectingAssertionsError: error,
- });
- }
-
- chai.util.addMethod(expect, 'assertions', assertions);
- chai.util.addMethod(expect, 'hasAssertions', hasAssertions);
-
- expect.extend(customMatchers);
-
- return expect;
-}
-
-const globalExpect: ExpectStatic = createExpect();
-
-Object.defineProperty(globalThis, GLOBAL_EXPECT, {
- value: globalExpect,
- writable: true,
- configurable: true,
-});
-
-export { assert, should } from 'chai';
-export { chai, globalExpect as expect };
-
-export type {
- Assertion,
- AsymmetricMatchersContaining,
- DeeplyAllowMatchers,
- ExpectStatic,
- JestAssertion,
- Matchers,
-} from '@vitest/expect';
+export * from './expect.js';
diff --git a/packages/runtime/src/expect/matchers/toMatchImageSnapshot.ts b/packages/runtime/src/expect/matchers/toMatchImageSnapshot.ts
new file mode 100644
index 0000000..c4cf046
--- /dev/null
+++ b/packages/runtime/src/expect/matchers/toMatchImageSnapshot.ts
@@ -0,0 +1,50 @@
+import { getClientInstance } from '../../client/store.js';
+import type { MatcherState } from '@vitest/expect';
+import {
+ type ImageSnapshotOptions,
+ generateTransferId,
+} from '@react-native-harness/bridge';
+import { getHarnessContext } from '../../runner/index.js';
+
+type ScreenshotResult = {
+ data: Uint8Array;
+ width: number;
+ height: number;
+};
+
+export async function toMatchImageSnapshot(
+ this: MatcherState,
+ received: ScreenshotResult,
+ options: ImageSnapshotOptions
+): Promise<{ pass: boolean; message: () => string }> {
+ const client = getClientInstance();
+ const context = getHarnessContext();
+
+ const transferId = generateTransferId();
+ client.sendBinary(transferId, received.data);
+
+ const screenshotFile = await client.rpc['device.screenshot.receive'](
+ {
+ type: 'binary',
+ transferId,
+ size: received.data.length,
+ mimeType: 'image/png',
+ },
+ {
+ width: received.width,
+ height: received.height,
+ }
+ );
+
+ const result = await client.rpc['test.matchImageSnapshot'](
+ screenshotFile,
+ context.testFilePath,
+ options,
+ context.runner
+ );
+
+ return {
+ pass: result.pass,
+ message: () => result.message,
+ };
+}
diff --git a/packages/runtime/src/globals.ts b/packages/runtime/src/globals.ts
index 71d11a8..783b9f1 100644
--- a/packages/runtime/src/globals.ts
+++ b/packages/runtime/src/globals.ts
@@ -1,3 +1,5 @@
+import type { ImageSnapshotOptions } from '@react-native-harness/bridge';
+
export type HarnessGlobal = {
appRegistryComponentName: string;
webSocketPort?: number;
@@ -7,6 +9,16 @@ declare global {
var RN_HARNESS: HarnessGlobal | undefined;
}
+declare module '@vitest/expect' {
+ interface Matchers {
+ /**
+ * Match the received screenshot against a stored snapshot.
+ * Creates a new snapshot if one doesn't exist.
+ */
+ toMatchImageSnapshot(options: ImageSnapshotOptions): Promise;
+ }
+}
+
export const getHarnessGlobal = (): HarnessGlobal => {
const harnessGlobal = global.RN_HARNESS;
diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts
index 1dc1c6e..70784c8 100644
--- a/packages/runtime/src/index.ts
+++ b/packages/runtime/src/index.ts
@@ -1,5 +1,5 @@
import './polyfills.js';
-import './globals.d.ts';
+import './globals.js';
export { UI as ReactNativeHarness } from './ui/index.js';
export * from './spy/index.js';
diff --git a/packages/runtime/src/render/TestComponentOverlay.tsx b/packages/runtime/src/render/TestComponentOverlay.tsx
index 621378f..607928d 100644
--- a/packages/runtime/src/render/TestComponentOverlay.tsx
+++ b/packages/runtime/src/render/TestComponentOverlay.tsx
@@ -4,6 +4,21 @@ import { useRenderedElement } from '../ui/state.js';
import { store } from '../ui/state.js';
import { ErrorBoundary } from './ErrorBoundary.js';
+/**
+ * Waits for the native view hierarchy to be fully updated.
+ * Uses double requestAnimationFrame to ensure native has processed
+ * all view creation commands after React's commit phase.
+ */
+const waitForNativeViewHierarchy = (): Promise => {
+ return new Promise((resolve) => {
+ requestAnimationFrame(() => {
+ requestAnimationFrame(() => {
+ resolve();
+ });
+ });
+ });
+};
+
export const TestComponentOverlay = (): React.ReactElement | null => {
const { element, key } = useRenderedElement();
@@ -12,8 +27,13 @@ export const TestComponentOverlay = (): React.ReactElement | null => {
const callback = store.getState().onRenderCallback;
if (callback) {
- callback();
- store.getState().setOnRenderCallback(null);
+ // Wait for native view hierarchy to be fully updated before calling callback.
+ // useEffect fires after React commits, but native processes commands async.
+ // Double rAF ensures native has finished processing all view creation.
+ waitForNativeViewHierarchy().then(() => {
+ callback();
+ store.getState().setOnRenderCallback(null);
+ });
}
}, [element]);
diff --git a/packages/runtime/src/render/index.ts b/packages/runtime/src/render/index.ts
index 9129859..30eb84a 100644
--- a/packages/runtime/src/render/index.ts
+++ b/packages/runtime/src/render/index.ts
@@ -25,16 +25,18 @@ export const render = async (
store.getState().setOnRenderCallback(null);
}
- // Create a promise that resolves when the element is laid out
- const layoutPromise = new Promise((resolve, reject) => {
+ // Create a promise that resolves when the element is rendered.
+ // We use onRenderCallback which fires in useEffect, guaranteeing that
+ // React has committed all children to the native view hierarchy.
+ const renderPromise = new Promise((resolve, reject) => {
const timeoutId = setTimeout(() => {
- store.getState().setOnLayoutCallback(null);
+ store.getState().setOnRenderCallback(null);
reject(
new Error(`Render timeout: Element did not mount within ${timeout}ms`)
);
}, timeout);
- store.getState().setOnLayoutCallback(() => {
+ store.getState().setOnRenderCallback(() => {
clearTimeout(timeoutId);
resolve();
});
@@ -44,8 +46,8 @@ export const render = async (
const wrappedElement = wrapElement(element, wrapper);
store.getState().setRenderedElement(wrappedElement);
- // Wait for layout
- await layoutPromise;
+ // Wait for useEffect to fire, ensuring all children are committed
+ await renderPromise;
const rerender = async (newElement: React.ReactElement): Promise => {
if (store.getState().renderedElement === null) {
diff --git a/packages/runtime/src/runner/context.ts b/packages/runtime/src/runner/context.ts
new file mode 100644
index 0000000..ded33a0
--- /dev/null
+++ b/packages/runtime/src/runner/context.ts
@@ -0,0 +1,16 @@
+export type HarnessContext = {
+ testFilePath: string;
+ runner: string;
+};
+
+declare global {
+ var HARNESS_CONTEXT: HarnessContext;
+}
+
+export const getHarnessContext = (): HarnessContext => {
+ return globalThis['HARNESS_CONTEXT'];
+};
+
+export const setHarnessContext = (context: HarnessContext): void => {
+ globalThis['HARNESS_CONTEXT'] = context;
+};
diff --git a/packages/runtime/src/runner/factory.ts b/packages/runtime/src/runner/factory.ts
index c78d60c..49c3be1 100644
--- a/packages/runtime/src/runner/factory.ts
+++ b/packages/runtime/src/runner/factory.ts
@@ -2,13 +2,19 @@ import type { TestRunnerEvents } from '@react-native-harness/bridge';
import { getEmitter } from '../utils/emitter.js';
import { runSuite } from './runSuite.js';
import { TestRunner } from './types.js';
+import { setHarnessContext } from './context.js';
export const getTestRunner = (): TestRunner => {
const events = getEmitter();
return {
events,
- run: async (testSuite, testFilePath) => {
+ run: async ({ testSuite, testFilePath, runner }) => {
+ setHarnessContext({
+ testFilePath,
+ runner,
+ });
+
const result = await runSuite(testSuite, {
events,
testFilePath,
diff --git a/packages/runtime/src/runner/index.ts b/packages/runtime/src/runner/index.ts
index d2e8802..0801835 100644
--- a/packages/runtime/src/runner/index.ts
+++ b/packages/runtime/src/runner/index.ts
@@ -5,3 +5,8 @@ export type {
} from './types.js';
export { TestExecutionError } from './errors.js';
export { getTestRunner } from './factory.js';
+export {
+ getHarnessContext,
+ setHarnessContext,
+ type HarnessContext,
+} from './context.js';
diff --git a/packages/runtime/src/runner/runSuite.ts b/packages/runtime/src/runner/runSuite.ts
index 9c1512d..c189d37 100644
--- a/packages/runtime/src/runner/runSuite.ts
+++ b/packages/runtime/src/runner/runSuite.ts
@@ -8,6 +8,10 @@ import { runHooks } from './hooks.js';
import { getTestExecutionError } from './errors.js';
import { TestRunnerContext } from './types.js';
+declare global {
+ var HARNESS_TEST_PATH: string;
+}
+
const runTest = async (
test: TestCase,
suite: TestSuite,
diff --git a/packages/runtime/src/runner/types.ts b/packages/runtime/src/runner/types.ts
index 4e31918..2ec75fb 100644
--- a/packages/runtime/src/runner/types.ts
+++ b/packages/runtime/src/runner/types.ts
@@ -12,8 +12,14 @@ export type TestRunnerContext = {
testFilePath: string;
};
+export type RunTestsOptions = {
+ testSuite: TestSuite;
+ testFilePath: string;
+ runner: string;
+};
+
export type TestRunner = {
events: TestRunnerEventsEmitter;
- run: (testSuite: TestSuite, testFilePath: string) => Promise;
+ run: (options: RunTestsOptions) => Promise;
dispose: () => void;
};
diff --git a/packages/runtime/src/ui/ReadyScreen.tsx b/packages/runtime/src/ui/ReadyScreen.tsx
index 4b20dcd..29dd8ab 100644
--- a/packages/runtime/src/ui/ReadyScreen.tsx
+++ b/packages/runtime/src/ui/ReadyScreen.tsx
@@ -16,6 +16,7 @@ export const ReadyScreen = () => {
return (
+
React Native Harness
diff --git a/packages/ui/HarnessUI.podspec b/packages/ui/HarnessUI.podspec
new file mode 100644
index 0000000..3ebfc46
--- /dev/null
+++ b/packages/ui/HarnessUI.podspec
@@ -0,0 +1,20 @@
+require "json"
+
+package = JSON.parse(File.read(File.join(__dir__, "package.json")))
+
+Pod::Spec.new do |s|
+ s.name = "HarnessUI"
+ s.version = package["version"]
+ s.summary = package["description"]
+ s.homepage = package["homepage"]
+ s.license = package["license"]
+ s.authors = package["author"]
+
+ s.platforms = { :ios => "13.0" }
+ s.source = { :git => "https://github.com/callstackincubator/react-native-harness.git", :tag => "#{s.version}" }
+
+ s.source_files = "ios/**/*.{h,m,mm,swift,cpp}"
+ s.private_header_files = "ios/**/*.h"
+
+ install_modules_dependencies(s)
+end
diff --git a/packages/ui/README.md b/packages/ui/README.md
new file mode 100644
index 0000000..66e3b92
--- /dev/null
+++ b/packages/ui/README.md
@@ -0,0 +1,164 @@
+
+
+### Native UI Testing Module for React Native Harness
+
+[![mit licence][license-badge]][license]
+[![npm downloads][npm-downloads-badge]][npm-downloads]
+[![Chat][chat-badge]][chat]
+[![PRs Welcome][prs-welcome-badge]][prs-welcome]
+
+Native UI testing module for React Native Harness that provides view queries and touch simulation capabilities. This module enables finding UI elements and simulating user interactions in your React Native tests.
+
+## Features
+
+- **View Queries**: Find elements by testID or accessibility label
+- **Touch Simulation**: Simulate user presses and text input
+- **Screenshot Capture**: Capture screenshots of the entire screen or specific elements
+- **Debug-Only**: Automatically excluded from release builds, only available in debug builds
+
+## Installation
+
+```bash
+npm install @react-native-harness/ui
+# or
+pnpm add @react-native-harness/ui
+# or
+yarn add @react-native-harness/ui
+```
+
+## Usage
+
+Import the UI testing utilities in your test files:
+
+```javascript
+import { screen, userEvent } from '@react-native-harness/ui';
+
+describe('My Component', () => {
+ it('should handle user interactions', async () => {
+ // Find elements on screen
+ const button = await screen.findByTestId('my-button');
+ const input = await screen.findByAccessibilityLabel('Username input');
+
+ // Simulate user interactions
+ await userEvent.type(input, 'testuser');
+ await userEvent.press(button);
+
+ // Take screenshots for debugging
+ const screenshot = await screen.screenshot();
+ });
+});
+```
+
+## API
+
+### `screen`
+
+Provides methods to query and interact with UI elements on screen.
+
+#### `findByTestId(testId: string): Promise`
+
+Finds an element by its testID (accessibilityIdentifier on iOS, tag on Android).
+Throws an error if no element is found.
+
+#### `findAllByTestId(testId: string): Promise`
+
+Finds all elements by testID. Throws an error if no elements are found.
+
+#### `queryByTestId(testId: string): ElementReference | null`
+
+Queries for an element by testID without throwing. Returns null if not found.
+
+#### `queryAllByTestId(testId: string): ElementReference[]`
+
+Queries for all elements by testID without throwing. Returns an empty array if none found.
+
+#### `findByAccessibilityLabel(label: string): Promise`
+
+Finds an element by its accessibility label. Throws an error if no element is found.
+
+#### `findAllByAccessibilityLabel(label: string): Promise`
+
+Finds all elements by accessibility label. Throws an error if no elements are found.
+
+#### `queryByAccessibilityLabel(label: string): ElementReference | null`
+
+Queries for an element by accessibility label without throwing. Returns null if not found.
+
+#### `queryAllByAccessibilityLabel(label: string): ElementReference[]`
+
+Queries for all elements by accessibility label without throwing. Returns an empty array if none found.
+
+#### `screenshot(element?: ElementReference): Promise`
+
+Captures a screenshot of the entire app window or a specific element.
+Returns a ScreenshotResult with PNG data, or null if capture fails.
+
+### `userEvent`
+
+Provides methods to simulate user interactions.
+
+#### `press(element: ElementReference): Promise`
+
+Simulates a press on the given element at its center point.
+
+#### `pressAt(x: number, y: number): Promise`
+
+Simulates a press at the specified screen coordinates.
+
+#### `type(element: ElementReference, text: string, options?: TypeOptions): Promise`
+
+Simulates typing text into a text input element. Focuses the element, types each character, and blurs the element.
+
+**TypeOptions:**
+- `skipPress?: boolean` - If true, pressIn and pressOut events will not be triggered
+- `skipBlur?: boolean` - If true, endEditing and blur events will not be triggered
+- `submitEditing?: boolean` - If true, submitEditing event will be triggered after typing
+
+## Types
+
+### `ElementReference`
+
+Represents an element found on screen with its position and dimensions.
+
+```typescript
+type ElementReference = {
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+ // ... additional view info
+};
+```
+
+### `ScreenshotResult`
+
+Screenshot result containing PNG image data.
+
+```typescript
+interface ScreenshotResult {
+ data: Uint8Array; // PNG image data
+ width: number; // Width in logical pixels
+ height: number; // Height in logical pixels
+}
+```
+
+## Requirements
+
+- React Native project with React Native Harness configured
+- This module is only available in debug builds and is automatically excluded from release builds
+
+## Made with ❤️ at Callstack
+
+`@react-native-harness/ui` is an open source project and will always remain free to use. If you think it's cool, please star it 🌟. [Callstack][callstack-readme-with-love] is a group of React and React Native geeks, contact us at [hello@callstack.com](mailto:hello@callstack.com) if you need any help with these or just want to say hi!
+
+Like the project? ⚛️ [Join the team](https://callstack.com/careers/?utm_campaign=Senior_RN&utm_source=github&utm_medium=readme) who does amazing stuff for clients and drives React Native Open Source! 🔥
+
+[callstack-readme-with-love]: https://callstack.com/?utm_source=github.com&utm_medium=referral&utm_campaign=react-native-harness&utm_term=readme-with-love
+[license-badge]: https://img.shields.io/npm/l/@react-native-harness/ui?style=for-the-badge
+[license]: https://github.com/callstackincubator/react-native-harness/blob/main/LICENSE
+[npm-downloads-badge]: https://img.shields.io/npm/dm/@react-native-harness/ui?style=for-the-badge
+[npm-downloads]: https://www.npmjs.com/package/@react-native-harness/ui
+[prs-welcome-badge]: https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=for-the-badge
+[prs-welcome]: ../../CONTRIBUTING.md
+[chat-badge]: https://img.shields.io/discord/426714625279524876.svg?style=for-the-badge
+[chat]: https://discord.gg/xgGt7KAjxv
\ No newline at end of file
diff --git a/packages/ui/android/build.gradle b/packages/ui/android/build.gradle
new file mode 100644
index 0000000..c005f79
--- /dev/null
+++ b/packages/ui/android/build.gradle
@@ -0,0 +1,77 @@
+buildscript {
+ ext.getExtOrDefault = {name ->
+ return rootProject.ext.has(name) ? rootProject.ext.get(name) : project.properties['HarnessUI_' + name]
+ }
+
+ repositories {
+ google()
+ mavenCentral()
+ }
+
+ dependencies {
+ classpath "com.android.tools.build:gradle:8.7.2"
+ // noinspection DifferentKotlinGradleVersion
+ classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:${getExtOrDefault('kotlinVersion')}"
+ }
+}
+
+
+apply plugin: "com.android.library"
+apply plugin: "kotlin-android"
+
+apply plugin: "com.facebook.react"
+
+def getExtOrIntegerDefault(name) {
+ return rootProject.ext.has(name) ? rootProject.ext.get(name) : (project.properties["HarnessUI_" + name]).toInteger()
+}
+
+android {
+ namespace "com.harnessui"
+
+ compileSdkVersion getExtOrIntegerDefault("compileSdkVersion")
+
+ defaultConfig {
+ minSdkVersion getExtOrIntegerDefault("minSdkVersion")
+ targetSdkVersion getExtOrIntegerDefault("targetSdkVersion")
+ }
+
+ buildFeatures {
+ buildConfig true
+ }
+
+ buildTypes {
+ release {
+ minifyEnabled false
+ }
+ }
+
+ lintOptions {
+ disable "GradleCompatible"
+ }
+
+ compileOptions {
+ sourceCompatibility JavaVersion.VERSION_1_8
+ targetCompatibility JavaVersion.VERSION_1_8
+ }
+
+ sourceSets {
+ main {
+ java.srcDirs += [
+ "generated/java",
+ "generated/jni"
+ ]
+ }
+ }
+}
+
+repositories {
+ mavenCentral()
+ google()
+}
+
+def kotlin_version = getExtOrDefault("kotlinVersion")
+
+dependencies {
+ implementation "com.facebook.react:react-android"
+ implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
+}
diff --git a/packages/ui/android/gradle.properties b/packages/ui/android/gradle.properties
new file mode 100644
index 0000000..6e8aae9
--- /dev/null
+++ b/packages/ui/android/gradle.properties
@@ -0,0 +1,5 @@
+HarnessUI_kotlinVersion=2.0.21
+HarnessUI_minSdkVersion=24
+HarnessUI_targetSdkVersion=34
+HarnessUI_compileSdkVersion=35
+HarnessUI_ndkVersion=27.1.12297006
diff --git a/packages/ui/android/src/main/java/com/harnessui/HarnessUIModule.kt b/packages/ui/android/src/main/java/com/harnessui/HarnessUIModule.kt
new file mode 100644
index 0000000..ac1e826
--- /dev/null
+++ b/packages/ui/android/src/main/java/com/harnessui/HarnessUIModule.kt
@@ -0,0 +1,53 @@
+package com.harnessui
+
+import com.facebook.react.bridge.Promise
+import com.facebook.react.bridge.ReactApplicationContext
+import com.facebook.react.bridge.ReadableMap
+import com.facebook.react.bridge.WritableArray
+import com.facebook.react.bridge.WritableMap
+import com.facebook.react.module.annotations.ReactModule
+
+/**
+ * TurboModule for HarnessUI - provides UI testing capabilities.
+ * Includes touch simulation and view querying.
+ */
+@ReactModule(name = HarnessUIModule.NAME)
+class HarnessUIModule(reactContext: ReactApplicationContext) :
+ NativeHarnessUISpec(reactContext) {
+
+ companion object {
+ const val NAME = "HarnessUI"
+ }
+
+ private val helper = UIHelperImpl(reactContext)
+
+ override fun getName(): String = NAME
+
+ override fun simulatePress(x: Double, y: Double, promise: Promise) {
+ helper.simulatePress(x, y, promise)
+ }
+
+ override fun queryByTestId(testId: String): WritableMap? =
+ helper.queryByTestId(testId)
+
+ override fun queryByAccessibilityLabel(label: String): WritableMap? =
+ helper.queryByAccessibilityLabel(label)
+
+ override fun queryAllByTestId(testId: String): WritableArray =
+ helper.queryAllByTestId(testId)
+
+ override fun queryAllByAccessibilityLabel(label: String): WritableArray =
+ helper.queryAllByAccessibilityLabel(label)
+
+ override fun captureScreenshot(bounds: ReadableMap?, promise: Promise) {
+ helper.captureScreenshot(bounds, promise)
+ }
+
+ override fun typeChar(character: String, promise: Promise) {
+ helper.typeChar(character, promise)
+ }
+
+ override fun blur(options: ReadableMap, promise: Promise) {
+ helper.blur(options, promise)
+ }
+}
diff --git a/packages/ui/android/src/main/java/com/harnessui/HarnessUIPackage.kt b/packages/ui/android/src/main/java/com/harnessui/HarnessUIPackage.kt
new file mode 100644
index 0000000..6c94621
--- /dev/null
+++ b/packages/ui/android/src/main/java/com/harnessui/HarnessUIPackage.kt
@@ -0,0 +1,33 @@
+package com.harnessui
+
+import com.facebook.react.BaseReactPackage
+import com.facebook.react.bridge.NativeModule
+import com.facebook.react.bridge.ReactApplicationContext
+import com.facebook.react.module.model.ReactModuleInfo
+import com.facebook.react.module.model.ReactModuleInfoProvider
+import java.util.HashMap
+
+class HarnessUIPackage : BaseReactPackage() {
+ override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? {
+ return if (name == HarnessUIModule.NAME) {
+ HarnessUIModule(reactContext)
+ } else {
+ null
+ }
+ }
+
+ override fun getReactModuleInfoProvider(): ReactModuleInfoProvider {
+ return ReactModuleInfoProvider {
+ val moduleInfos: MutableMap = HashMap()
+ moduleInfos[HarnessUIModule.NAME] = ReactModuleInfo(
+ HarnessUIModule.NAME,
+ HarnessUIModule.NAME,
+ false, // canOverrideExistingModule
+ false, // needsEagerInit
+ false, // isCxxModule
+ true // isTurboModule
+ )
+ moduleInfos
+ }
+ }
+}
diff --git a/packages/ui/android/src/main/java/com/harnessui/UIHelperImpl.kt b/packages/ui/android/src/main/java/com/harnessui/UIHelperImpl.kt
new file mode 100644
index 0000000..e33e27e
--- /dev/null
+++ b/packages/ui/android/src/main/java/com/harnessui/UIHelperImpl.kt
@@ -0,0 +1,335 @@
+package com.harnessui
+
+import android.graphics.Bitmap
+import android.graphics.Canvas
+import android.os.Handler
+import android.os.Looper
+import android.os.SystemClock
+import android.util.Log
+import android.view.MotionEvent
+import android.view.inputmethod.EditorInfo
+import android.view.inputmethod.InputMethodManager
+import android.content.Context
+import android.widget.EditText
+import com.facebook.react.bridge.Arguments
+import com.facebook.react.bridge.Promise
+import com.facebook.react.bridge.ReactApplicationContext
+import com.facebook.react.bridge.ReadableMap
+import com.facebook.react.bridge.UiThreadUtil
+import com.facebook.react.bridge.WritableArray
+import com.facebook.react.bridge.WritableMap
+import com.facebook.react.bridge.WritableNativeArray
+import java.io.ByteArrayOutputStream
+import java.nio.ByteBuffer
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.TimeUnit
+
+/**
+ * UI helper implementation for HarnessUI.
+ * Includes touch simulation and view querying capabilities.
+ */
+class UIHelperImpl(private val context: ReactApplicationContext) {
+
+ companion object {
+ private const val TAG = "HarnessUI"
+ private const val TAP_DURATION_MS = 50L // Duration between touch down and up
+ private const val EVENT_PROCESSING_DELAY_MS = 10L // Delay after touch up for React Native to process the event
+ }
+
+ private val mainHandler = Handler(Looper.getMainLooper())
+
+ // =========================================================================
+ // Touch Simulation
+ // =========================================================================
+
+ fun simulatePress(x: Double, y: Double, promise: Promise) {
+ Log.i(TAG, "simulatePress called with x:$x y:$y")
+
+ UiThreadUtil.runOnUiThread {
+ val activity = context.currentActivity ?: run {
+ Log.w(TAG, "No current activity")
+ promise.resolve(null)
+ return@runOnUiThread
+ }
+ val root = activity.window.decorView
+
+ // Convert DP to PX
+ val density = root.resources.displayMetrics.density
+ val pxX = (x * density).toFloat()
+ val pxY = (y * density).toFloat()
+
+ val downTime = SystemClock.uptimeMillis()
+
+ // 1. ACTION_DOWN
+ val downEvent = MotionEvent.obtain(downTime, downTime, MotionEvent.ACTION_DOWN, pxX, pxY, 0)
+ try {
+ root.dispatchTouchEvent(downEvent)
+ Log.i(TAG, "Sent touch down at ($pxX, $pxY)")
+ } finally {
+ downEvent.recycle()
+ }
+
+ // 2. ACTION_UP after real delay to allow press feedback to render
+ mainHandler.postDelayed({
+ val upTime = SystemClock.uptimeMillis()
+ val upEvent = MotionEvent.obtain(downTime, upTime, MotionEvent.ACTION_UP, pxX, pxY, 0)
+ try {
+ root.dispatchTouchEvent(upEvent)
+ Log.i(TAG, "Tap completed at ($pxX, $pxY)")
+ } finally {
+ upEvent.recycle()
+ }
+ // Wait for React Native to process the touch event and trigger JS callbacks
+ mainHandler.postDelayed({
+ promise.resolve(null)
+ }, EVENT_PROCESSING_DELAY_MS)
+ }, TAP_DURATION_MS)
+ }
+ }
+
+ // =========================================================================
+ // Query API
+ // =========================================================================
+
+ fun queryByTestId(testId: String): WritableMap? {
+ Log.i(TAG, "queryByTestId called with: $testId")
+ return executeQuery(ViewQueryType.TEST_ID, testId)
+ }
+
+ fun queryByAccessibilityLabel(label: String): WritableMap? {
+ Log.i(TAG, "queryByAccessibilityLabel called with: $label")
+ return executeQuery(ViewQueryType.ACCESSIBILITY_LABEL, label)
+ }
+
+ fun queryAllByTestId(testId: String): WritableArray {
+ Log.i(TAG, "queryAllByTestId called with: $testId")
+ return executeQueryAll(ViewQueryType.TEST_ID, testId)
+ }
+
+ fun queryAllByAccessibilityLabel(label: String): WritableArray {
+ Log.i(TAG, "queryAllByAccessibilityLabel called with: $label")
+ return executeQueryAll(ViewQueryType.ACCESSIBILITY_LABEL, label)
+ }
+
+ /**
+ * Executes a query on the UI thread and returns the result.
+ * Uses CountDownLatch to synchronize with the UI thread.
+ */
+ private fun executeQuery(queryType: ViewQueryType, value: String): WritableMap? {
+ var result: WritableMap? = null
+
+ // If already on UI thread, execute directly
+ if (UiThreadUtil.isOnUiThread()) {
+ val activity = context.currentActivity ?: return null
+ result = ViewQueryHelper.query(activity, queryType, value)?.toWritableMap()
+ } else {
+ // Execute on UI thread and wait for result
+ val latch = CountDownLatch(1)
+
+ UiThreadUtil.runOnUiThread {
+ try {
+ val activity = context.currentActivity
+ if (activity != null) {
+ result = ViewQueryHelper.query(activity, queryType, value)?.toWritableMap()
+ }
+ } finally {
+ latch.countDown()
+ }
+ }
+
+ // Wait for UI thread with timeout
+ try {
+ latch.await(5, TimeUnit.SECONDS)
+ } catch (e: InterruptedException) {
+ Log.e(TAG, "Query interrupted", e)
+ }
+ }
+
+ Log.i(TAG, "Query result: $result")
+ return result
+ }
+
+ /**
+ * Executes a query for all matching views on the UI thread.
+ * Uses CountDownLatch to synchronize with the UI thread.
+ */
+ private fun executeQueryAll(queryType: ViewQueryType, value: String): WritableArray {
+ var result: WritableArray = Arguments.createArray()
+
+ // If already on UI thread, execute directly
+ if (UiThreadUtil.isOnUiThread()) {
+ val activity = context.currentActivity ?: return result
+ val queryResults = ViewQueryHelper.queryAll(activity, queryType, value)
+ result = Arguments.createArray().apply {
+ queryResults.forEach { pushMap(it.toWritableMap()) }
+ }
+ } else {
+ // Execute on UI thread and wait for result
+ val latch = CountDownLatch(1)
+
+ UiThreadUtil.runOnUiThread {
+ try {
+ val activity = context.currentActivity
+ if (activity != null) {
+ val queryResults = ViewQueryHelper.queryAll(activity, queryType, value)
+ result = Arguments.createArray().apply {
+ queryResults.forEach { pushMap(it.toWritableMap()) }
+ }
+ }
+ } finally {
+ latch.countDown()
+ }
+ }
+
+ // Wait for UI thread with timeout
+ try {
+ latch.await(5, TimeUnit.SECONDS)
+ } catch (e: InterruptedException) {
+ Log.e(TAG, "QueryAll interrupted", e)
+ }
+ }
+
+ Log.i(TAG, "QueryAll result count: ${result.size()}")
+ return result
+ }
+
+ // =========================================================================
+ // Screenshot Capture
+ // =========================================================================
+
+ fun captureScreenshot(bounds: ReadableMap?, promise: Promise) {
+ Log.i(TAG, "captureScreenshot called")
+
+ UiThreadUtil.runOnUiThread {
+ val activity = context.currentActivity ?: run {
+ Log.w(TAG, "No current activity")
+ promise.resolve(null)
+ return@runOnUiThread
+ }
+
+ val root = activity.window.decorView.rootView
+ val density = root.resources.displayMetrics.density
+
+ try {
+ // Determine capture dimensions
+ val captureX: Int
+ val captureY: Int
+ val captureWidth: Int
+ val captureHeight: Int
+
+ // Check if bounds are provided and have non-zero dimensions
+ val hasBounds = bounds != null && bounds.getDouble("width") > 0 && bounds.getDouble("height") > 0
+
+ if (hasBounds) {
+ // Convert dp to px for bounds
+ captureX = (bounds!!.getDouble("x") * density).toInt()
+ captureY = (bounds.getDouble("y") * density).toInt()
+ captureWidth = (bounds.getDouble("width") * density).toInt()
+ captureHeight = (bounds.getDouble("height") * density).toInt()
+ Log.i(TAG, "Capturing region: x=$captureX y=$captureY w=$captureWidth h=$captureHeight")
+ } else {
+ captureX = 0
+ captureY = 0
+ captureWidth = root.width
+ captureHeight = root.height
+ Log.i(TAG, "Capturing full window: w=$captureWidth h=$captureHeight")
+ }
+
+ // Create bitmap and canvas
+ val bitmap = Bitmap.createBitmap(captureWidth, captureHeight, Bitmap.Config.ARGB_8888)
+ val canvas = Canvas(bitmap)
+
+ // Translate canvas if capturing a specific region
+ canvas.translate(-captureX.toFloat(), -captureY.toFloat())
+
+ // Draw the view hierarchy
+ root.draw(canvas)
+
+ // Convert to PNG bytes
+ val outputStream = ByteArrayOutputStream()
+ bitmap.compress(Bitmap.CompressFormat.PNG, 100, outputStream)
+ val pngBytes = outputStream.toByteArray()
+
+ // Clean up
+ bitmap.recycle()
+
+ // Return as Base64 string for efficiency
+ val base64String = android.util.Base64.encodeToString(pngBytes, android.util.Base64.NO_WRAP)
+ Log.i(TAG, "Screenshot captured successfully (${pngBytes.size} bytes)")
+ promise.resolve(base64String)
+ } catch (e: Exception) {
+ Log.e(TAG, "Screenshot capture failed", e)
+ promise.resolve(null)
+ }
+ }
+ }
+
+ // =========================================================================
+ // Text Input
+ // =========================================================================
+
+ fun typeChar(character: String, promise: Promise) {
+ Log.i(TAG, "typeChar called with: $character")
+
+ UiThreadUtil.runOnUiThread {
+ val activity = context.currentActivity ?: run {
+ Log.w(TAG, "No current activity")
+ promise.resolve(null)
+ return@runOnUiThread
+ }
+
+ val focused = activity.currentFocus
+ if (focused is EditText) {
+ // Insert character at cursor position
+ val start = focused.selectionStart
+ val end = focused.selectionEnd
+ focused.text.replace(start, end, character)
+ Log.i(TAG, "Inserted character: $character")
+ } else {
+ Log.i(TAG, "No EditText is focused")
+ }
+
+ // Delay for React Native to process onChangeText
+ mainHandler.postDelayed({
+ promise.resolve(null)
+ }, EVENT_PROCESSING_DELAY_MS)
+ }
+ }
+
+ fun blur(options: ReadableMap, promise: Promise) {
+ val submitEditing = options.getBoolean("submitEditing")
+ Log.i(TAG, "blur called, submitEditing: $submitEditing")
+
+ UiThreadUtil.runOnUiThread {
+ val activity = context.currentActivity ?: run {
+ Log.w(TAG, "No current activity")
+ promise.resolve(null)
+ return@runOnUiThread
+ }
+
+ val focused = activity.currentFocus
+
+ if (submitEditing && focused is EditText) {
+ Log.i(TAG, "Triggering submitEditing")
+ // Trigger onSubmitEditing by simulating IME action
+ focused.onEditorAction(EditorInfo.IME_ACTION_DONE)
+ }
+
+ if (focused != null) {
+ // Clear focus (triggers onEndEditing and onBlur)
+ Log.i(TAG, "Clearing focus")
+ focused.clearFocus()
+
+ // Hide keyboard
+ val imm = activity.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
+ imm.hideSoftInputFromWindow(focused.windowToken, 0)
+ } else {
+ Log.i(TAG, "No view is focused")
+ }
+
+ mainHandler.postDelayed({
+ promise.resolve(null)
+ }, EVENT_PROCESSING_DELAY_MS)
+ }
+ }
+}
diff --git a/packages/ui/android/src/main/java/com/harnessui/ViewQueryHelper.kt b/packages/ui/android/src/main/java/com/harnessui/ViewQueryHelper.kt
new file mode 100644
index 0000000..fdf77b6
--- /dev/null
+++ b/packages/ui/android/src/main/java/com/harnessui/ViewQueryHelper.kt
@@ -0,0 +1,135 @@
+package com.harnessui
+
+import android.app.Activity
+import android.view.View
+import android.view.ViewGroup
+import com.facebook.react.bridge.Arguments
+import com.facebook.react.bridge.WritableMap
+
+/**
+ * Enum defining the types of queries that can be performed on the view hierarchy.
+ */
+enum class ViewQueryType {
+ TEST_ID,
+ ACCESSIBILITY_LABEL
+}
+
+/**
+ * Represents information about a found view.
+ */
+data class ViewQueryResult(
+ val x: Float,
+ val y: Float,
+ val width: Float,
+ val height: Float
+) {
+ fun toWritableMap(): WritableMap {
+ return Arguments.createMap().apply {
+ putDouble("x", x.toDouble())
+ putDouble("y", y.toDouble())
+ putDouble("width", width.toDouble())
+ putDouble("height", height.toDouble())
+ }
+ }
+}
+
+/**
+ * Helper object for querying the view hierarchy.
+ * Provides reusable query logic for finding views by various criteria.
+ */
+object ViewQueryHelper {
+
+ /**
+ * Finds the first view matching the query criteria.
+ * @param activity The current activity.
+ * @param queryType The type of query to perform.
+ * @param value The value to match against.
+ * @return ViewQueryResult if found, null otherwise.
+ */
+ fun query(activity: Activity, queryType: ViewQueryType, value: String): ViewQueryResult? {
+ val root = activity.window.decorView
+ val density = root.resources.displayMetrics.density
+ val found = findViewInView(root, queryType, value) ?: return null
+ return resultFromView(found, density)
+ }
+
+ /**
+ * Finds all views matching the query criteria.
+ * @param activity The current activity.
+ * @param queryType The type of query to perform.
+ * @param value The value to match against.
+ * @return List of ViewQueryResult objects.
+ */
+ fun queryAll(activity: Activity, queryType: ViewQueryType, value: String): List {
+ val root = activity.window.decorView
+ val density = root.resources.displayMetrics.density
+ val views = mutableListOf()
+ findAllViewsInView(root, queryType, value, views)
+ return views.map { resultFromView(it, density) }
+ }
+
+ /**
+ * Checks if a view matches the given query criteria.
+ */
+ private fun viewMatches(view: View, queryType: ViewQueryType, value: String): Boolean {
+ return when (queryType) {
+ ViewQueryType.TEST_ID -> view.tag == value
+ ViewQueryType.ACCESSIBILITY_LABEL -> view.contentDescription?.toString() == value
+ }
+ }
+
+ /**
+ * Recursively finds the first view matching the query criteria.
+ */
+ private fun findViewInView(view: View, queryType: ViewQueryType, value: String): View? {
+ if (viewMatches(view, queryType, value)) {
+ return view
+ }
+
+ if (view is ViewGroup) {
+ for (i in 0 until view.childCount) {
+ val found = findViewInView(view.getChildAt(i), queryType, value)
+ if (found != null) {
+ return found
+ }
+ }
+ }
+ return null
+ }
+
+ /**
+ * Recursively finds all views matching the query criteria.
+ */
+ private fun findAllViewsInView(
+ view: View,
+ queryType: ViewQueryType,
+ value: String,
+ results: MutableList
+ ) {
+ if (viewMatches(view, queryType, value)) {
+ results.add(view)
+ }
+
+ if (view is ViewGroup) {
+ for (i in 0 until view.childCount) {
+ findAllViewsInView(view.getChildAt(i), queryType, value, results)
+ }
+ }
+ }
+
+ /**
+ * Converts a View to a ViewQueryResult with screen coordinates in dp.
+ */
+ private fun resultFromView(view: View, density: Float): ViewQueryResult {
+ val location = IntArray(2)
+ view.getLocationOnScreen(location)
+
+ // Convert pixels to dp
+ val x = location[0] / density
+ val y = location[1] / density
+ val width = view.width / density
+ val height = view.height / density
+
+ return ViewQueryResult(x, y, width, height)
+ }
+}
diff --git a/packages/ui/eslint.config.mjs b/packages/ui/eslint.config.mjs
new file mode 100644
index 0000000..c334bc0
--- /dev/null
+++ b/packages/ui/eslint.config.mjs
@@ -0,0 +1,19 @@
+import baseConfig from '../../eslint.config.mjs';
+
+export default [
+ ...baseConfig,
+ {
+ files: ['**/*.json'],
+ rules: {
+ '@nx/dependency-checks': [
+ 'error',
+ {
+ ignoredFiles: ['{projectRoot}/eslint.config.{js,cjs,mjs,ts,cts,mts}'],
+ },
+ ],
+ },
+ languageOptions: {
+ parser: await import('jsonc-eslint-parser'),
+ },
+ },
+];
diff --git a/packages/ui/ios/HarnessUI.h b/packages/ui/ios/HarnessUI.h
new file mode 100644
index 0000000..3695d1b
--- /dev/null
+++ b/packages/ui/ios/HarnessUI.h
@@ -0,0 +1,5 @@
+#import
+
+@interface HarnessUI : NSObject
+
+@end
diff --git a/packages/ui/ios/HarnessUI.mm b/packages/ui/ios/HarnessUI.mm
new file mode 100644
index 0000000..dcd118a
--- /dev/null
+++ b/packages/ui/ios/HarnessUI.mm
@@ -0,0 +1,526 @@
+#import "HarnessUI.h"
+#import "ViewQueryHelper.h"
+#import
+#import
+#import
+#import
+#import
+#import
+
+// Private UITouch methods for touch synthesis
+@interface UITouch (Synthesis)
+- (void)setPhase:(UITouchPhase)phase;
+- (void)setTapCount:(NSUInteger)tapCount;
+- (void)setIsTap:(BOOL)isTap;
+- (void)setTimestamp:(NSTimeInterval)timestamp;
+- (void)setWindow:(UIWindow *)window;
+- (void)setView:(UIView *)view;
+- (void)_setLocationInWindow:(CGPoint)location resetPrevious:(BOOL)reset;
+- (void)_setIsFirstTouchForView:(BOOL)first;
+- (void)_setTouchIdentifier:(unsigned int)identifier;
+@end
+
+// Private UIEvent methods for event synthesis
+@interface UIEvent (Synthesis)
+- (void)_addTouch:(UITouch *)touch forDelayedDelivery:(BOOL)delayed;
+- (void)_clearTouches;
+- (void)_setTimestamp:(NSTimeInterval)timestamp;
+- (void)_setHIDEvent:(void *)event;
+@end
+
+// Private UIApplication method for event synthesis
+@interface UIApplication (Synthesis)
+- (UIEvent *)_touchesEvent;
+@end
+
+// Configuration constants
+static const int kMaxRetryCount =
+ 10; // Maximum retries waiting for active touches
+static const NSTimeInterval kRetryInterval = 0.05; // 50ms between retries
+static const NSTimeInterval kSettleDelay =
+ 0.01; // 10ms delay for system to settle
+static const NSTimeInterval kTapDuration =
+ 0.05; // 50ms between touch began and ended
+static const NSTimeInterval kEventProcessingDelay =
+ 0.01; // 10ms delay after touch ended for React Native to process the event
+static const NSTimeInterval kMinTapInterval =
+ 0.15; // 150ms minimum between taps
+
+static unsigned int _touchIdCounter = 100;
+static NSTimeInterval _lastTapTime = 0;
+
+@implementation HarnessUI
+
+#pragma mark - Window Access
+
+- (UIWindow *)getActiveWindow {
+ return [ViewQueryHelper getActiveWindow];
+}
+
+#pragma mark - Touch Synthesis
+
+- (void)sendTouchEvent:(UITouch *)touch
+ event:(UIEvent *)event
+ phase:(UITouchPhase)phase
+ point:(CGPoint)point {
+ NSTimeInterval timestamp = [[NSProcessInfo processInfo] systemUptime];
+
+ NSString *phaseName;
+ switch (phase) {
+ case UITouchPhaseBegan:
+ phaseName = @"began";
+ break;
+ case UITouchPhaseMoved:
+ phaseName = @"moved";
+ break;
+ case UITouchPhaseStationary:
+ phaseName = @"stationary";
+ break;
+ case UITouchPhaseEnded:
+ phaseName = @"ended";
+ break;
+ case UITouchPhaseCancelled:
+ phaseName = @"cancelled";
+ break;
+ default:
+ phaseName = @"unknown";
+ }
+ RCTLogInfo(@"[HarnessUI] Sending touch event: phase=%@ point=(%.2f, %.2f)",
+ phaseName, point.x, point.y);
+
+ [touch setPhase:phase];
+ [touch setTimestamp:timestamp];
+ [touch _setLocationInWindow:point resetPrevious:(phase == UITouchPhaseBegan)];
+
+ [event _clearTouches];
+ [event _addTouch:touch forDelayedDelivery:NO];
+ [event _setTimestamp:timestamp];
+
+ [[UIApplication sharedApplication] sendEvent:event];
+}
+
+- (BOOL)hasActiveTouches:(UIWindow *)window {
+ // Check if there are any active touches on the window
+ UIEvent *event = [[UIApplication sharedApplication] _touchesEvent];
+ NSSet *allTouches = [event allTouches];
+
+ for (UITouch *touch in allTouches) {
+ UITouchPhase phase = touch.phase;
+ if (phase == UITouchPhaseBegan || phase == UITouchPhaseMoved ||
+ phase == UITouchPhaseStationary) {
+ RCTLogInfo(@"[HarnessUI] Found active touch in phase %ld", (long)phase);
+ return YES;
+ }
+ }
+ return NO;
+}
+
+- (void)executeTapAtPoint:(CGPoint)point
+ retryCount:(int)retryCount
+ completion:(void (^)(void))completion {
+ UIWindow *window = [self getActiveWindow];
+ if (!window) {
+ RCTLogInfo(@"[HarnessUI] No active window found");
+ if (completion)
+ completion();
+ return;
+ }
+
+ // Check for active real touches - wait for them to finish
+ if ([self hasActiveTouches:window]) {
+ if (retryCount < kMaxRetryCount) {
+ RCTLogInfo(@"[HarnessUI] Active touches detected, waiting... (retry %d)",
+ retryCount);
+ dispatch_after(dispatch_time(DISPATCH_TIME_NOW,
+ (int64_t)(kRetryInterval * NSEC_PER_SEC)),
+ dispatch_get_main_queue(), ^{
+ [self executeTapAtPoint:point
+ retryCount:retryCount + 1
+ completion:completion];
+ });
+ return;
+ } else {
+ RCTLogInfo(
+ @"[HarnessUI] Timeout waiting for touches to end, proceeding anyway");
+ }
+ }
+
+ UIView *targetView = [window hitTest:point withEvent:nil];
+ if (!targetView) {
+ RCTLogInfo(@"[HarnessUI] No view at point (%.2f, %.2f)", point.x, point.y);
+ if (completion)
+ completion();
+ return;
+ }
+
+ RCTLogInfo(@"[HarnessUI] Target view: %@", targetView);
+
+ // Get the internal touches event from UIApplication
+ UIEvent *event = [[UIApplication sharedApplication] _touchesEvent];
+
+ // Aggressively clear all existing state
+ [event _clearTouches];
+ if ([event respondsToSelector:@selector(_setHIDEvent:)]) {
+ [event _setHIDEvent:NULL];
+ }
+
+ // Small delay after clearing to let the system settle
+ dispatch_after(
+ dispatch_time(DISPATCH_TIME_NOW, (int64_t)(kSettleDelay * NSEC_PER_SEC)),
+ dispatch_get_main_queue(), ^{
+ // Create fresh touch with unique identifier (handle overflow)
+ UITouch *touch = [[UITouch alloc] init];
+ _touchIdCounter =
+ (_touchIdCounter >= UINT_MAX - 1) ? 100 : _touchIdCounter + 1;
+ unsigned int touchId = _touchIdCounter;
+
+ if ([touch respondsToSelector:@selector(_setTouchIdentifier:)]) {
+ [touch _setTouchIdentifier:touchId];
+ }
+
+ // Configure touch
+ [touch setWindow:window];
+ [touch setView:targetView];
+ [touch setTapCount:1];
+ if ([touch respondsToSelector:@selector(_setIsFirstTouchForView:)]) {
+ [touch _setIsFirstTouchForView:YES];
+ }
+ if ([touch respondsToSelector:@selector(setIsTap:)]) {
+ [touch setIsTap:YES];
+ }
+
+ // Re-fetch event (in case it changed) and clear again
+ UIEvent *freshEvent = [[UIApplication sharedApplication] _touchesEvent];
+ [freshEvent _clearTouches];
+
+ // Send began
+ [self sendTouchEvent:touch
+ event:freshEvent
+ phase:UITouchPhaseBegan
+ point:point];
+ RCTLogInfo(@"[HarnessUI] Sent touch began (id=%u)", touchId);
+
+ // Schedule ended phase - re-fetch event to avoid race condition with
+ // real touches
+ dispatch_after(
+ dispatch_time(DISPATCH_TIME_NOW,
+ (int64_t)(kTapDuration * NSEC_PER_SEC)),
+ dispatch_get_main_queue(), ^{
+ UIEvent *endEvent =
+ [[UIApplication sharedApplication] _touchesEvent];
+ [self sendTouchEvent:touch
+ event:endEvent
+ phase:UITouchPhaseEnded
+ point:point];
+ [endEvent _clearTouches];
+ RCTLogInfo(@"[HarnessUI] Tap completed (id=%u)", touchId);
+ // Wait for React Native to process the touch event
+ // and trigger JS callbacks before resolving
+ dispatch_after(dispatch_time(DISPATCH_TIME_NOW,
+ (int64_t)(kEventProcessingDelay *
+ NSEC_PER_SEC)),
+ dispatch_get_main_queue(), ^{
+ if (completion)
+ completion();
+ });
+ });
+ });
+}
+
+- (void)executeTapAtPoint:(CGPoint)point completion:(void (^)(void))completion {
+ [self executeTapAtPoint:point retryCount:0 completion:completion];
+}
+
+- (void)simulatePress:(double)x
+ y:(double)y
+ resolve:(RCTPromiseResolveBlock)resolve
+ reject:(RCTPromiseRejectBlock)reject {
+ RCTLogInfo(@"[HarnessUI] simulatePress called with x:%.2f y:%.2f", x, y);
+
+ dispatch_async(dispatch_get_main_queue(), ^{
+ NSTimeInterval now = CACurrentMediaTime();
+ NSTimeInterval timeSinceLastTap = now - _lastTapTime;
+ CGPoint point = CGPointMake(x, y);
+
+ // Completion block that actually resolves the promise
+ void (^completionBlock)(void) = ^{
+ // Check if tap was successful by verifying we found a window/view
+ UIWindow *window = [self getActiveWindow];
+ if (!window) {
+ reject(@"NO_WINDOW", @"No active window found", nil);
+ return;
+ }
+
+ UIView *targetView = [window hitTest:point withEvent:nil];
+ if (!targetView) {
+ reject(
+ @"NO_VIEW",
+ [NSString
+ stringWithFormat:@"No view found at point (%.2f, %.2f)", x, y],
+ nil);
+ return;
+ }
+
+ // Success - resolve with success info
+ resolve(@{@"success" : @YES, @"x" : @(x), @"y" : @(y)});
+ };
+
+ if (timeSinceLastTap < kMinTapInterval) {
+ NSTimeInterval delay = kMinTapInterval - timeSinceLastTap;
+ RCTLogInfo(@"[HarnessUI] Delaying tap by %.0fms", delay * 1000);
+ _lastTapTime = now + delay;
+
+ dispatch_after(
+ dispatch_time(DISPATCH_TIME_NOW, (int64_t)(delay * NSEC_PER_SEC)),
+ dispatch_get_main_queue(), ^{
+ [self executeTapAtPoint:point completion:completionBlock];
+ });
+ } else {
+ _lastTapTime = now;
+ [self executeTapAtPoint:point completion:completionBlock];
+ }
+ });
+}
+
+#pragma mark - Query API
+
+- (NSDictionary *)queryByTestId:(NSString *)testId {
+ RCTLogInfo(@"[HarnessUI] queryByTestId called with: %@", testId);
+
+ __block NSDictionary *result = nil;
+
+ if ([NSThread isMainThread]) {
+ ViewQueryResult *queryResult =
+ [ViewQueryHelper queryWithType:ViewQueryTypeTestId value:testId];
+ result = queryResult ? [queryResult toDictionary] : nil;
+ } else {
+ dispatch_sync(dispatch_get_main_queue(), ^{
+ ViewQueryResult *queryResult =
+ [ViewQueryHelper queryWithType:ViewQueryTypeTestId value:testId];
+ result = queryResult ? [queryResult toDictionary] : nil;
+ });
+ }
+
+ RCTLogInfo(@"[HarnessUI] queryByTestId result: %@", result);
+ return result;
+}
+
+- (NSDictionary *)queryByAccessibilityLabel:(NSString *)label {
+ RCTLogInfo(@"[HarnessUI] queryByAccessibilityLabel called with: %@", label);
+
+ __block NSDictionary *result = nil;
+
+ if ([NSThread isMainThread]) {
+ ViewQueryResult *queryResult =
+ [ViewQueryHelper queryWithType:ViewQueryTypeAccessibilityLabel
+ value:label];
+ result = queryResult ? [queryResult toDictionary] : nil;
+ } else {
+ dispatch_sync(dispatch_get_main_queue(), ^{
+ ViewQueryResult *queryResult =
+ [ViewQueryHelper queryWithType:ViewQueryTypeAccessibilityLabel
+ value:label];
+ result = queryResult ? [queryResult toDictionary] : nil;
+ });
+ }
+
+ RCTLogInfo(@"[HarnessUI] queryByAccessibilityLabel result: %@", result);
+ return result;
+}
+
+- (NSArray *)queryAllByTestId:(NSString *)testId {
+ RCTLogInfo(@"[HarnessUI] queryAllByTestId called with: %@", testId);
+
+ __block NSArray *result = @[];
+
+ if ([NSThread isMainThread]) {
+ NSArray *queryResults =
+ [ViewQueryHelper queryAllWithType:ViewQueryTypeTestId value:testId];
+ NSMutableArray *dicts =
+ [NSMutableArray arrayWithCapacity:queryResults.count];
+ for (ViewQueryResult *queryResult in queryResults) {
+ [dicts addObject:[queryResult toDictionary]];
+ }
+ result = dicts;
+ } else {
+ dispatch_sync(dispatch_get_main_queue(), ^{
+ NSArray *queryResults =
+ [ViewQueryHelper queryAllWithType:ViewQueryTypeTestId value:testId];
+ NSMutableArray *dicts =
+ [NSMutableArray arrayWithCapacity:queryResults.count];
+ for (ViewQueryResult *queryResult in queryResults) {
+ [dicts addObject:[queryResult toDictionary]];
+ }
+ result = dicts;
+ });
+ }
+
+ RCTLogInfo(@"[HarnessUI] queryAllByTestId result count: %lu",
+ (unsigned long)result.count);
+ return result;
+}
+
+- (NSArray *)queryAllByAccessibilityLabel:(NSString *)label {
+ RCTLogInfo(@"[HarnessUI] queryAllByAccessibilityLabel called with: %@", label);
+
+ __block NSArray *result = @[];
+
+ if ([NSThread isMainThread]) {
+ NSArray *queryResults =
+ [ViewQueryHelper queryAllWithType:ViewQueryTypeAccessibilityLabel
+ value:label];
+ NSMutableArray *dicts =
+ [NSMutableArray arrayWithCapacity:queryResults.count];
+ for (ViewQueryResult *queryResult in queryResults) {
+ [dicts addObject:[queryResult toDictionary]];
+ }
+ result = dicts;
+ } else {
+ dispatch_sync(dispatch_get_main_queue(), ^{
+ NSArray *queryResults =
+ [ViewQueryHelper queryAllWithType:ViewQueryTypeAccessibilityLabel
+ value:label];
+ NSMutableArray *dicts =
+ [NSMutableArray arrayWithCapacity:queryResults.count];
+ for (ViewQueryResult *queryResult in queryResults) {
+ [dicts addObject:[queryResult toDictionary]];
+ }
+ result = dicts;
+ });
+ }
+
+ RCTLogInfo(@"[HarnessUI] queryAllByAccessibilityLabel result count: %lu",
+ (unsigned long)result.count);
+ return result;
+}
+
+#pragma mark - Screenshot API
+
+- (void)captureScreenshot:(JS::NativeHarnessUI::ViewInfo *)bounds
+ resolve:(RCTPromiseResolveBlock)resolve
+ reject:(RCTPromiseRejectBlock)reject {
+ RCTLogInfo(@"[HarnessUI] captureScreenshot called");
+
+ CGRect captureRect = CGRectNull;
+
+ if (bounds) {
+ double width = bounds->width();
+ double height = bounds->height();
+
+ if (width > 0 && height > 0) {
+ captureRect = CGRectMake(bounds->x(), bounds->y(), width, height);
+ RCTLogInfo(@"[HarnessUI] Capturing region: x=%.2f y=%.2f w=%.2f h=%.2f",
+ captureRect.origin.x, captureRect.origin.y,
+ captureRect.size.width, captureRect.size.height);
+ }
+ } else {
+ RCTLogInfo(@"[HarnessUI] Capturing full window");
+ }
+
+ dispatch_async(dispatch_get_main_queue(), ^{
+ NSData *pngData = [ViewQueryHelper captureScreenshotWithBounds:captureRect];
+
+ if (pngData) {
+ // Return Base64 string for efficiency
+ NSString *base64String = [pngData base64EncodedStringWithOptions:0];
+ RCTLogInfo(@"[HarnessUI] Screenshot captured successfully (%lu bytes)",
+ (unsigned long)pngData.length);
+ resolve(base64String);
+ } else {
+ RCTLogInfo(@"[HarnessUI] Screenshot capture returned nil");
+ resolve([NSNull null]);
+ }
+ });
+}
+
+#pragma mark - Text Input
+
+- (UIResponder *)findFirstResponderInView:(UIView *)view {
+ if ([view isFirstResponder]) {
+ return view;
+ }
+ for (UIView *subview in view.subviews) {
+ UIResponder *responder = [self findFirstResponderInView:subview];
+ if (responder) {
+ return responder;
+ }
+ }
+ return nil;
+}
+
+- (void)typeChar:(NSString *)character
+ resolve:(RCTPromiseResolveBlock)resolve
+ reject:(RCTPromiseRejectBlock)reject {
+ RCTLogInfo(@"[HarnessUI] typeChar called with: %@", character);
+
+ dispatch_async(dispatch_get_main_queue(), ^{
+ UIWindow *window = [self getActiveWindow];
+ UIResponder *firstResponder = [self findFirstResponderInView:window];
+
+ if ([firstResponder conformsToProtocol:@protocol(UITextInput)]) {
+ id textInput = (id)firstResponder;
+ [textInput insertText:character];
+ RCTLogInfo(@"[HarnessUI] Inserted character: %@", character);
+ } else {
+ RCTLogInfo(@"[HarnessUI] No text input is focused");
+ }
+
+ // Small delay for React Native to process the change event
+ dispatch_after(dispatch_time(DISPATCH_TIME_NOW,
+ (int64_t)(kEventProcessingDelay * NSEC_PER_SEC)),
+ dispatch_get_main_queue(), ^{
+ resolve(nil);
+ });
+ });
+}
+
+- (void)blur:(JS::NativeHarnessUI::SpecBlurOptions &)options
+ resolve:(RCTPromiseResolveBlock)resolve
+ reject:(RCTPromiseRejectBlock)reject {
+ RCTLogInfo(@"[HarnessUI] blur called");
+
+ // Extract value before async dispatch to avoid dangling reference
+ BOOL shouldSubmitEditing = options.submitEditing().value_or(false);
+
+ dispatch_async(dispatch_get_main_queue(), ^{
+ UIWindow *window = [self getActiveWindow];
+ UIResponder *firstResponder = [self findFirstResponderInView:window];
+
+ if (firstResponder) {
+ if (shouldSubmitEditing) {
+ RCTLogInfo(@"[HarnessUI] Triggering submitEditing");
+ // Trigger submitEditing event if the responder supports it
+ // For UITextField, this simulates pressing Return
+ if ([firstResponder isKindOfClass:[UITextField class]]) {
+ UITextField *textField = (UITextField *)firstResponder;
+ [textField.delegate textFieldShouldReturn:textField];
+ }
+ }
+
+ // Resign first responder (triggers endEditing and blur events)
+ RCTLogInfo(@"[HarnessUI] Resigning first responder");
+ [firstResponder resignFirstResponder];
+ } else {
+ RCTLogInfo(@"[HarnessUI] No first responder found");
+ }
+
+ dispatch_after(dispatch_time(DISPATCH_TIME_NOW,
+ (int64_t)(kEventProcessingDelay * NSEC_PER_SEC)),
+ dispatch_get_main_queue(), ^{
+ resolve(nil);
+ });
+ });
+}
+
+#pragma mark - TurboModule plumbing
+
+- (std::shared_ptr)getTurboModule:
+ (const facebook::react::ObjCTurboModule::InitParams &)params {
+ return std::make_shared(params);
+}
+
++ (NSString *)moduleName {
+ return @"HarnessUI";
+}
+
+@end
diff --git a/packages/ui/ios/ViewQueryHelper.h b/packages/ui/ios/ViewQueryHelper.h
new file mode 100644
index 0000000..83d5fb2
--- /dev/null
+++ b/packages/ui/ios/ViewQueryHelper.h
@@ -0,0 +1,64 @@
+#import
+
+NS_ASSUME_NONNULL_BEGIN
+
+/**
+ * Enum defining the types of queries that can be performed on the view hierarchy.
+ * Add new query types here to extend functionality.
+ */
+typedef NS_ENUM(NSInteger, ViewQueryType) {
+ ViewQueryTypeTestId, // Matches accessibilityIdentifier
+ ViewQueryTypeAccessibilityLabel // Matches accessibilityLabel
+};
+
+/**
+ * Represents information about a found view.
+ */
+@interface ViewQueryResult : NSObject
+
+@property (nonatomic, assign) CGFloat x;
+@property (nonatomic, assign) CGFloat y;
+@property (nonatomic, assign) CGFloat width;
+@property (nonatomic, assign) CGFloat height;
+
+- (NSDictionary *)toDictionary;
+
+@end
+
+/**
+ * Helper class for querying the view hierarchy.
+ * Provides reusable query logic for finding views by various criteria.
+ */
+@interface ViewQueryHelper : NSObject
+
+/**
+ * Returns the currently active window.
+ */
++ (nullable UIWindow *)getActiveWindow;
+
+/**
+ * Finds the first view matching the query criteria.
+ * @param queryType The type of query to perform.
+ * @param value The value to match against.
+ * @return ViewQueryResult if found, nil otherwise.
+ */
++ (nullable ViewQueryResult *)queryWithType:(ViewQueryType)queryType value:(NSString *)value;
+
+/**
+ * Finds all views matching the query criteria.
+ * @param queryType The type of query to perform.
+ * @param value The value to match against.
+ * @return Array of ViewQueryResult objects.
+ */
++ (NSArray *)queryAllWithType:(ViewQueryType)queryType value:(NSString *)value;
+
+/**
+ * Captures a screenshot of the window or a specific region.
+ * @param bounds Optional CGRect for capturing a specific region. Pass CGRectNull for full window.
+ * @return NSData containing PNG image data, or nil on failure.
+ */
++ (nullable NSData *)captureScreenshotWithBounds:(CGRect)bounds;
+
+@end
+
+NS_ASSUME_NONNULL_END
diff --git a/packages/ui/ios/ViewQueryHelper.mm b/packages/ui/ios/ViewQueryHelper.mm
new file mode 100644
index 0000000..90e911b
--- /dev/null
+++ b/packages/ui/ios/ViewQueryHelper.mm
@@ -0,0 +1,187 @@
+#import "ViewQueryHelper.h"
+
+// =============================================================================
+// ViewQueryResult Implementation
+// =============================================================================
+
+@implementation ViewQueryResult
+
+- (NSDictionary *)toDictionary {
+ return @{
+ @"x": @(self.x),
+ @"y": @(self.y),
+ @"width": @(self.width),
+ @"height": @(self.height)
+ };
+}
+
+@end
+
+// =============================================================================
+// ViewQueryHelper Implementation
+// =============================================================================
+
+@implementation ViewQueryHelper
+
+#pragma mark - Window Access
+
++ (nullable UIWindow *)getActiveWindow {
+ UIWindow *window = nil;
+ if (@available(iOS 13.0, *)) {
+ for (UIWindowScene *scene in [UIApplication sharedApplication].connectedScenes) {
+ if (scene.activationState == UISceneActivationStateForegroundActive) {
+ for (UIWindow *w in scene.windows) {
+ if (w.isKeyWindow) {
+ window = w;
+ break;
+ }
+ }
+ if (!window) {
+ window = scene.windows.firstObject;
+ }
+ break;
+ }
+ }
+ } else {
+ window = [UIApplication sharedApplication].keyWindow;
+ }
+ return window;
+}
+
+#pragma mark - View Matching
+
+/**
+ * Checks if a view matches the given query criteria.
+ * Add new query type matching logic here.
+ */
++ (BOOL)view:(UIView *)view matchesQueryType:(ViewQueryType)queryType value:(NSString *)value {
+ switch (queryType) {
+ case ViewQueryTypeTestId:
+ return [view.accessibilityIdentifier isEqualToString:value];
+
+ case ViewQueryTypeAccessibilityLabel:
+ return [view.accessibilityLabel isEqualToString:value];
+
+ // Add new query types here:
+ // case ViewQueryTypeNewType:
+ // return [view.someProperty isEqualToString:value];
+ }
+ return NO;
+}
+
+#pragma mark - View Traversal
+
+/**
+ * Recursively finds the first view matching the query criteria.
+ */
++ (nullable UIView *)findViewInView:(UIView *)view
+ queryType:(ViewQueryType)queryType
+ value:(NSString *)value {
+ if ([self view:view matchesQueryType:queryType value:value]) {
+ return view;
+ }
+
+ for (UIView *subview in view.subviews) {
+ UIView *found = [self findViewInView:subview queryType:queryType value:value];
+ if (found) {
+ return found;
+ }
+ }
+ return nil;
+}
+
+/**
+ * Recursively finds all views matching the query criteria.
+ */
++ (void)findAllViewsInView:(UIView *)view
+ queryType:(ViewQueryType)queryType
+ value:(NSString *)value
+ results:(NSMutableArray *)results {
+ if ([self view:view matchesQueryType:queryType value:value]) {
+ [results addObject:view];
+ }
+
+ for (UIView *subview in view.subviews) {
+ [self findAllViewsInView:subview queryType:queryType value:value results:results];
+ }
+}
+
+#pragma mark - Result Conversion
+
+/**
+ * Converts a UIView to a ViewQueryResult with screen coordinates.
+ */
++ (ViewQueryResult *)resultFromView:(UIView *)view window:(UIWindow *)window {
+ CGRect frameInWindow = [view convertRect:view.bounds toView:window];
+
+ ViewQueryResult *result = [[ViewQueryResult alloc] init];
+ result.x = frameInWindow.origin.x;
+ result.y = frameInWindow.origin.y;
+ result.width = frameInWindow.size.width;
+ result.height = frameInWindow.size.height;
+ return result;
+}
+
+#pragma mark - Public Query API
+
++ (nullable ViewQueryResult *)queryWithType:(ViewQueryType)queryType value:(NSString *)value {
+ UIWindow *window = [self getActiveWindow];
+ if (!window) {
+ return nil;
+ }
+
+ UIView *found = [self findViewInView:window queryType:queryType value:value];
+ if (!found) {
+ return nil;
+ }
+
+ return [self resultFromView:found window:window];
+}
+
++ (NSArray *)queryAllWithType:(ViewQueryType)queryType value:(NSString *)value {
+ UIWindow *window = [self getActiveWindow];
+ if (!window) {
+ return @[];
+ }
+
+ NSMutableArray *views = [NSMutableArray array];
+ [self findAllViewsInView:window queryType:queryType value:value results:views];
+
+ NSMutableArray *results = [NSMutableArray arrayWithCapacity:views.count];
+ for (UIView *view in views) {
+ [results addObject:[self resultFromView:view window:window]];
+ }
+ return results;
+}
+
+#pragma mark - Screenshot Capture
+
++ (nullable NSData *)captureScreenshotWithBounds:(CGRect)bounds {
+ UIWindow *window = [self getActiveWindow];
+ if (!window) {
+ return nil;
+ }
+
+ // Determine capture rect
+ CGRect captureRect = CGRectIsNull(bounds) ? window.bounds : bounds;
+
+ // Use UIGraphicsImageRenderer for modern, efficient rendering
+ UIGraphicsImageRendererFormat *format = [[UIGraphicsImageRendererFormat alloc] init];
+ format.scale = [UIScreen mainScreen].scale;
+ format.opaque = YES;
+
+ UIGraphicsImageRenderer *renderer = [[UIGraphicsImageRenderer alloc] initWithSize:captureRect.size format:format];
+
+ UIImage *image = [renderer imageWithActions:^(UIGraphicsImageRendererContext *context) {
+ // Translate to capture the correct region
+ CGContextTranslateCTM(context.CGContext, -captureRect.origin.x, -captureRect.origin.y);
+
+ // Draw the window hierarchy
+ [window drawViewHierarchyInRect:window.bounds afterScreenUpdates:YES];
+ }];
+
+ // Convert to PNG
+ return UIImagePNGRepresentation(image);
+}
+
+@end
diff --git a/packages/ui/package.json b/packages/ui/package.json
new file mode 100644
index 0000000..cdae238
--- /dev/null
+++ b/packages/ui/package.json
@@ -0,0 +1,64 @@
+{
+ "name": "@react-native-harness/ui",
+ "description": "Native UI testing module for React Native Harness.",
+ "version": "1.0.0-alpha.20",
+ "type": "module",
+ "main": "./dist/index.js",
+ "module": "./dist/index.js",
+ "types": "./dist/index.d.ts",
+ "files": [
+ "src",
+ "dist",
+ "android",
+ "ios",
+ "*.podspec",
+ "react-native.config.js",
+ "!ios/build",
+ "!android/build",
+ "!android/gradle",
+ "!android/gradlew",
+ "!android/gradlew.bat",
+ "!android/local.properties",
+ "!**/__tests__",
+ "!**/__fixtures__",
+ "!**/__mocks__",
+ "!**/.*"
+ ],
+ "exports": {
+ "./package.json": "./package.json",
+ ".": {
+ "development": "./src/index.ts",
+ "types": "./dist/index.d.ts",
+ "import": "./dist/index.js",
+ "default": "./dist/index.js"
+ }
+ },
+ "peerDependencies": {
+ "react-native": "*"
+ },
+ "dependencies": {
+ "tslib": "^2.3.0",
+ "@react-native-harness/runtime": "workspace:*"
+ },
+ "devDependencies": {
+ "react-native": "*"
+ },
+ "codegenConfig": {
+ "name": "HarnessUISpec",
+ "type": "modules",
+ "jsSrcsDir": "src",
+ "android": {
+ "javaPackageName": "com.harnessui"
+ }
+ },
+ "author": {
+ "name": "Szymon Chmal",
+ "email": "szymon.chmal@callstack.com"
+ },
+ "homepage": "https://github.com/callstackincubator/react-native-harness",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/callstackincubator/react-native-harness.git"
+ },
+ "license": "MIT"
+}
diff --git a/packages/ui/react-native.config.cjs b/packages/ui/react-native.config.cjs
new file mode 100644
index 0000000..ba17417
--- /dev/null
+++ b/packages/ui/react-native.config.cjs
@@ -0,0 +1,12 @@
+module.exports = {
+ dependency: {
+ platforms: {
+ ios: {
+ configurations: ['debug'],
+ },
+ android: {
+ buildTypes: ['debug'],
+ },
+ },
+ },
+};
\ No newline at end of file
diff --git a/packages/ui/src/NativeHarnessUI.ts b/packages/ui/src/NativeHarnessUI.ts
new file mode 100644
index 0000000..03dd294
--- /dev/null
+++ b/packages/ui/src/NativeHarnessUI.ts
@@ -0,0 +1,66 @@
+import { TurboModuleRegistry, type TurboModule } from 'react-native';
+
+/**
+ * Represents the position and dimensions of a view in screen coordinates (points/dp).
+ */
+export interface ViewInfo {
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+}
+
+interface Spec extends TurboModule {
+ /**
+ * Simulates a native press at the specified screen coordinates.
+ * Returns a promise that resolves when the press action is complete.
+ */
+ simulatePress(x: number, y: number): Promise;
+
+ /**
+ * Finds a view by its testID (accessibilityIdentifier on iOS, tag on Android).
+ * Returns null if no view is found.
+ */
+ queryByTestId(testId: string): ViewInfo | null;
+
+ /**
+ * Finds all views by testID (accessibilityIdentifier on iOS, tag on Android).
+ * Returns an empty array if no views are found.
+ */
+ queryAllByTestId(testId: string): ViewInfo[];
+
+ /**
+ * Finds a view by its accessibility label.
+ * Returns null if no view is found.
+ */
+ queryByAccessibilityLabel(label: string): ViewInfo | null;
+
+ /**
+ * Finds all views by accessibility label.
+ * Returns an empty array if no views are found.
+ */
+ queryAllByAccessibilityLabel(label: string): ViewInfo[];
+
+ /**
+ * Captures a screenshot of the app UI.
+ * @param bounds Optional bounds to capture a specific region. Pass null to capture the entire window.
+ * @returns Promise resolving to Base64 encoded string containing PNG data, or null on failure.
+ */
+ captureScreenshot(bounds: ViewInfo | null): Promise;
+
+ /**
+ * Types a single character into the currently focused text input.
+ * If no text input is focused, this is a no-op.
+ */
+ typeChar(character: string): Promise;
+
+ /**
+ * Blurs (resigns first responder from) the currently focused element.
+ * Optionally triggers submitEditing event before blur.
+ */
+ blur(options: { submitEditing?: boolean }): Promise;
+}
+
+export type HarnessUIModule = Spec;
+
+export default TurboModuleRegistry.getEnforcing('HarnessUI');
diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts
new file mode 100644
index 0000000..ebe421b
--- /dev/null
+++ b/packages/ui/src/index.ts
@@ -0,0 +1,18 @@
+/**
+ * @react-native-harness/ui
+ *
+ * Native UI testing module for React Native Harness.
+ * Provides view queries and touch simulation.
+ *
+ * This module is automatically excluded from release builds
+ * and only available in debug builds.
+ */
+
+export {
+ screen,
+ type Screen,
+ type ElementReference,
+ type ScreenshotResult,
+} from './screen.js';
+export { userEvent, type UserEvent } from './userEvent.js';
+export type { ViewInfo } from './NativeHarnessUI.js';
diff --git a/packages/ui/src/screen.ts b/packages/ui/src/screen.ts
new file mode 100644
index 0000000..715e417
--- /dev/null
+++ b/packages/ui/src/screen.ts
@@ -0,0 +1,175 @@
+import NativeHarnessUI, { type ViewInfo } from './NativeHarnessUI.js';
+import { waitFor } from '@react-native-harness/runtime';
+
+/**
+ * Represents an element found on screen with its position and dimensions.
+ * This can be used with userEvent.press() to interact with the element.
+ */
+export type ElementReference = ViewInfo;
+
+/**
+ * Screenshot result containing PNG image data.
+ */
+export interface ScreenshotResult {
+ /** PNG image data as Uint8Array (ArrayBuffer view) */
+ data: Uint8Array;
+ /** Width of the captured image in logical pixels (points/dp) */
+ width: number;
+ /** Height of the captured image in logical pixels (points/dp) */
+ height: number;
+}
+
+export type Screen = {
+ /**
+ * Finds an element by its testID (accessibilityIdentifier on iOS, tag on Android).
+ * @throws Error if no element is found with the given testID.
+ */
+ findByTestId: (testId: string) => Promise;
+
+ /**
+ * Finds all elements by testID (accessibilityIdentifier on iOS, tag on Android).
+ * @throws Error if no elements are found with the given testID.
+ */
+ findAllByTestId: (testId: string) => Promise;
+
+ /**
+ * Queries for an element by its testID without throwing.
+ * Returns null if no element is found.
+ */
+ queryByTestId: (testId: string) => ElementReference | null;
+
+ /**
+ * Queries for all elements by testID without throwing.
+ * Returns an empty array if no elements are found.
+ */
+ queryAllByTestId: (testId: string) => ElementReference[];
+
+ /**
+ * Finds an element by its accessibility label.
+ * @throws Error if no element is found with the given label.
+ */
+ findByAccessibilityLabel: (label: string) => Promise;
+
+ /**
+ * Finds all elements by accessibility label.
+ * @throws Error if no elements are found with the given label.
+ */
+ findAllByAccessibilityLabel: (label: string) => Promise;
+
+ /**
+ * Queries for an element by its accessibility label without throwing.
+ * Returns null if no element is found.
+ */
+ queryByAccessibilityLabel: (label: string) => ElementReference | null;
+
+ /**
+ * Queries for all elements by accessibility label without throwing.
+ * Returns an empty array if no elements are found.
+ */
+ queryAllByAccessibilityLabel: (label: string) => ElementReference[];
+
+ /**
+ * Captures a screenshot of the entire app window or a specific element.
+ * @param element Optional element reference to capture. If not provided, captures the entire window.
+ * @returns Promise resolving to ScreenshotResult with PNG data, or null if capture fails.
+ */
+ screenshot: (element?: ElementReference) => Promise;
+};
+
+const createScreen = (): Screen => {
+ return {
+ findByTestId: async (testId: string): Promise => {
+ return waitFor(() => {
+ const result = NativeHarnessUI.queryByTestId(testId);
+ if (!result) {
+ throw new Error(`Unable to find element with testID: ${testId}`);
+ }
+ return result;
+ });
+ },
+
+ findAllByTestId: async (testId: string): Promise => {
+ return waitFor(() => {
+ const results = NativeHarnessUI.queryAllByTestId(testId);
+ if (results.length === 0) {
+ throw new Error(`Unable to find any elements with testID: ${testId}`);
+ }
+ return results;
+ });
+ },
+
+ queryByTestId: (testId: string): ElementReference | null => {
+ return NativeHarnessUI.queryByTestId(testId);
+ },
+
+ queryAllByTestId: (testId: string): ElementReference[] => {
+ return NativeHarnessUI.queryAllByTestId(testId);
+ },
+
+ findByAccessibilityLabel: async (
+ label: string
+ ): Promise => {
+ return waitFor(() => {
+ const result = NativeHarnessUI.queryByAccessibilityLabel(label);
+ if (!result) {
+ throw new Error(
+ `Unable to find element with accessibility label: ${label}`
+ );
+ }
+ return result;
+ });
+ },
+
+ findAllByAccessibilityLabel: async (
+ label: string
+ ): Promise => {
+ return waitFor(() => {
+ const results = NativeHarnessUI.queryAllByAccessibilityLabel(label);
+ if (results.length === 0) {
+ throw new Error(
+ `Unable to find any elements with accessibility label: ${label}`
+ );
+ }
+ return results;
+ });
+ },
+
+ queryByAccessibilityLabel: (label: string): ElementReference | null => {
+ return NativeHarnessUI.queryByAccessibilityLabel(label);
+ },
+
+ queryAllByAccessibilityLabel: (label: string): ElementReference[] => {
+ return NativeHarnessUI.queryAllByAccessibilityLabel(label);
+ },
+
+ screenshot: async (
+ element?: ElementReference
+ ): Promise => {
+ const bounds = element ?? null;
+ const base64String = await NativeHarnessUI.captureScreenshot(bounds);
+
+ if (!base64String) {
+ return null;
+ }
+
+ const width = element?.width ?? 0;
+ const height = element?.height ?? 0;
+
+ // Decode Base64 string to Uint8Array
+ const binaryString = atob(base64String);
+ const len = binaryString.length;
+ const bytes = new Uint8Array(len);
+ for (let i = 0; i < len; i++) {
+ bytes[i] = binaryString.charCodeAt(i);
+ }
+
+ return {
+ data: bytes,
+ width,
+ height,
+ };
+ },
+ };
+};
+
+export const screen = createScreen();
diff --git a/packages/ui/src/userEvent.ts b/packages/ui/src/userEvent.ts
new file mode 100644
index 0000000..3951639
--- /dev/null
+++ b/packages/ui/src/userEvent.ts
@@ -0,0 +1,124 @@
+import NativeHarnessUI from './NativeHarnessUI.js';
+import type { ElementReference } from './screen.js';
+
+/**
+ * Flushes pending events on the JS event loop.
+ * This ensures that any events triggered by native code (like onPress callbacks)
+ * are processed before we continue.
+ *
+ * Uses requestAnimationFrame to wait for the next frame render, ensuring React
+ * has had time to process events and commit state updates. Includes a small
+ * timeout buffer for slow devices.
+ */
+const flushEvents = (): Promise => {
+ return new Promise((resolve) => {
+ // Wait for the next frame - ensures React has processed events and rendered
+ requestAnimationFrame(() => {
+ // Wait for another frame to handle any cascading updates
+ requestAnimationFrame(() => {
+ // Small timeout buffer for slow devices to complete any async processing
+ setTimeout(resolve, 16);
+ });
+ });
+ });
+};
+
+export type UserEvent = {
+ /**
+ * Simulates a press on the given element.
+ * The press occurs at the center of the element's bounds.
+ * Returns a promise that resolves when the press is complete.
+ */
+ press: (element: ElementReference) => Promise;
+
+ /**
+ * Simulates a press at the specified screen coordinates.
+ * Returns a promise that resolves when the press is complete.
+ */
+ pressAt: (x: number, y: number) => Promise;
+
+ /**
+ * Simulates typing text into a text input element.
+ * This helper focuses the element by tapping it, types text one character at a time,
+ * and then blurs the element (unless skipBlur is true).
+ *
+ * @param element - The element to type into (should be a TextInput)
+ * @param text - The text to type
+ * @param options - Optional configuration
+ * @param options.skipPress - If true, pressIn and pressOut events will not be triggered
+ * @param options.skipBlur - If true, endEditing and blur events will not be triggered when typing is complete
+ * @param options.submitEditing - If true, submitEditing event will be triggered after typing the text
+ */
+ type: (
+ element: ElementReference,
+ text: string,
+ options?: {
+ skipPress?: boolean;
+ skipBlur?: boolean;
+ submitEditing?: boolean;
+ }
+ ) => Promise;
+};
+
+const createUserEvent = (): UserEvent => {
+ return {
+ press: async (element: ElementReference): Promise => {
+ // Calculate center point of the element
+ const centerX = element.x + element.width / 2;
+ const centerY = element.y + element.height / 2;
+ await NativeHarnessUI.simulatePress(centerX, centerY);
+ // Flush pending events to ensure onPress and other callbacks are processed
+ await flushEvents();
+ },
+
+ pressAt: async (x: number, y: number): Promise => {
+ await NativeHarnessUI.simulatePress(x, y);
+ // Flush pending events to ensure onPress and other callbacks are processed
+ await flushEvents();
+ },
+
+ type: async (
+ element: ElementReference,
+ text: string,
+ options?: {
+ skipPress?: boolean;
+ skipBlur?: boolean;
+ submitEditing?: boolean;
+ }
+ ): Promise => {
+ // Press to focus the element (triggers pressIn/pressOut unless skipPress is true)
+ // Note: Currently we always press to focus, the skipPress option would need
+ // additional implementation in simulatePress to avoid firing press events
+ if (!options?.skipPress) {
+ // Calculate center point of the element
+ const centerX = element.x + element.width / 2;
+ const centerY = element.y + element.height / 2;
+ await NativeHarnessUI.simulatePress(centerX, centerY);
+ await flushEvents();
+ } else {
+ // Still need to press to focus, but ideally without press events
+ // For now, we press anyway - future enhancement could add a focusOnly method
+ const centerX = element.x + element.width / 2;
+ const centerY = element.y + element.height / 2;
+ await NativeHarnessUI.simulatePress(centerX, centerY);
+ await flushEvents();
+ }
+
+ // Type each character one by one
+ for (const char of text) {
+ await NativeHarnessUI.typeChar(char);
+ await flushEvents(); // Let onChangeText fire
+ }
+
+ // Blur (triggers endEditing and blur unless skipBlur)
+ if (!options?.skipBlur) {
+ await NativeHarnessUI.blur({
+ submitEditing: options?.submitEditing ?? false,
+ });
+ await flushEvents();
+ }
+ },
+ };
+};
+
+export const userEvent = createUserEvent();
diff --git a/packages/ui/tsconfig.json b/packages/ui/tsconfig.json
new file mode 100644
index 0000000..d5bc1ae
--- /dev/null
+++ b/packages/ui/tsconfig.json
@@ -0,0 +1,13 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "files": [],
+ "include": [],
+ "references": [
+ {
+ "path": "../runtime"
+ },
+ {
+ "path": "./tsconfig.lib.json"
+ }
+ ]
+}
diff --git a/packages/ui/tsconfig.lib.json b/packages/ui/tsconfig.lib.json
new file mode 100644
index 0000000..7a4a13a
--- /dev/null
+++ b/packages/ui/tsconfig.lib.json
@@ -0,0 +1,19 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "baseUrl": ".",
+ "rootDir": "src",
+ "outDir": "dist",
+ "tsBuildInfoFile": "dist/tsconfig.lib.tsbuildinfo",
+ "emitDeclarationOnly": false,
+ "forceConsistentCasingInFileNames": true,
+ "types": ["node"]
+ },
+ "include": ["src/**/*.ts"],
+ "references": [
+ {
+ "path": "../runtime/tsconfig.lib.json"
+ }
+ ],
+ "exclude": ["src/**/*.test.ts", "src/**/*.spec.ts"]
+}
diff --git a/packages/ui/tsconfig.tsbuildinfo b/packages/ui/tsconfig.tsbuildinfo
new file mode 100644
index 0000000..7cf2f68
--- /dev/null
+++ b/packages/ui/tsconfig.tsbuildinfo
@@ -0,0 +1 @@
+{"fileNames":[],"fileInfos":[],"root":[],"options":{"composite":true,"declarationMap":true,"emitDeclarationOnly":true,"importHelpers":true,"module":199,"noEmitOnError":true,"noFallthroughCasesInSwitch":true,"noImplicitOverride":true,"noImplicitReturns":true,"noUnusedLocals":true,"skipLibCheck":true,"strict":true,"target":9},"version":"5.9.3"}
\ No newline at end of file
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 4530536..1de4b9b 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -31,13 +31,13 @@ importers:
version: 22.0.4(@babel/traverse@7.27.4)(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.17))(@swc/types@0.1.23)(typescript@5.9.3))(@swc/core@1.5.29(@swc/helpers@0.5.17))(nx@22.0.4(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.17))(@swc/types@0.1.23)(typescript@5.9.3))(@swc/core@1.5.29(@swc/helpers@0.5.17)))
'@nx/react-native':
specifier: 22.0.4
- version: 22.0.4(j5mvkpom3exmbpfctoxl2tygky)
+ version: 22.0.4(b6bdbf441fca35f932c3e030670387b0)
'@nx/rollup':
specifier: 22.0.4
version: 22.0.4(@babel/core@7.27.4)(@babel/traverse@7.27.4)(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.17))(@swc/types@0.1.23)(typescript@5.9.3))(@swc/core@1.5.29(@swc/helpers@0.5.17))(@types/babel__core@7.20.5)(nx@22.0.4(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.17))(@swc/types@0.1.23)(typescript@5.9.3))(@swc/core@1.5.29(@swc/helpers@0.5.17)))(ts-node@9.1.1(typescript@5.9.3))(typescript@5.9.3)
'@nx/vite':
specifier: 22.0.4
- version: 22.0.4(@babel/traverse@7.27.4)(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.17))(@swc/types@0.1.23)(typescript@5.9.3))(@swc/core@1.5.29(@swc/helpers@0.5.17))(nx@22.0.4(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.17))(@swc/types@0.1.23)(typescript@5.9.3))(@swc/core@1.5.29(@swc/helpers@0.5.17)))(typescript@5.9.3)(vite@7.2.2(@types/node@20.19.25)(jiti@2.4.2)(less@4.1.3)(lightningcss@1.27.0)(sass-embedded@1.89.2)(sass@1.89.2)(stylus@0.64.0)(terser@5.42.0)(yaml@2.8.0))(vitest@3.2.4(@types/debug@4.1.12)(@types/node@20.19.25)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@22.1.0)(less@4.1.3)(lightningcss@1.27.0)(sass-embedded@1.89.2)(sass@1.89.2)(stylus@0.64.0)(terser@5.42.0)(yaml@2.8.0))
+ version: 22.0.4(@babel/traverse@7.27.4)(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.17))(@swc/types@0.1.23)(typescript@5.9.3))(@swc/core@1.5.29(@swc/helpers@0.5.17))(nx@22.0.4(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.17))(@swc/types@0.1.23)(typescript@5.9.3))(@swc/core@1.5.29(@swc/helpers@0.5.17)))(typescript@5.9.3)(vite@7.2.2(@types/node@20.19.25)(jiti@2.4.2)(less@4.1.3)(lightningcss@1.27.0)(sass-embedded@1.89.2)(sass@1.89.2)(stylus@0.64.0)(terser@5.42.0)(yaml@2.8.0))(vitest@3.2.4)
'@nx/web':
specifier: 22.0.4
version: 22.0.4(@babel/traverse@7.27.4)(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.17))(@swc/types@0.1.23)(typescript@5.9.3))(@swc/core@1.5.29(@swc/helpers@0.5.17))(nx@22.0.4(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.17))(@swc/types@0.1.23)(typescript@5.9.3))(@swc/core@1.5.29(@swc/helpers@0.5.17)))
@@ -64,7 +64,7 @@ importers:
version: 4.6.0(vite@7.2.2(@types/node@20.19.25)(jiti@2.4.2)(less@4.1.3)(lightningcss@1.27.0)(sass-embedded@1.89.2)(sass@1.89.2)(stylus@0.64.0)(terser@5.42.0)(yaml@2.8.0))
'@vitest/coverage-v8':
specifier: ^3.0.5
- version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@20.19.25)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@22.1.0)(less@4.1.3)(lightningcss@1.27.0)(sass-embedded@1.89.2)(sass@1.89.2)(stylus@0.64.0)(terser@5.42.0)(yaml@2.8.0))
+ version: 3.2.4(vitest@3.2.4)
'@vitest/ui':
specifier: ^3.0.0
version: 3.2.4(vitest@3.2.4)
@@ -156,6 +156,12 @@ importers:
'@react-native-harness/platform-vega':
specifier: workspace:*
version: link:../../packages/platform-vega
+ '@react-native-harness/runtime':
+ specifier: workspace:*
+ version: link:../../packages/runtime
+ '@react-native-harness/ui':
+ specifier: workspace:*
+ version: link:../../packages/ui
'@react-native/babel-preset':
specifier: 0.82.1
version: 0.82.1(@babel/core@7.27.4)
@@ -193,12 +199,24 @@ importers:
packages/bridge:
dependencies:
+ '@react-native-harness/platforms':
+ specifier: workspace:*
+ version: link:../platforms
'@react-native-harness/tools':
specifier: workspace:*
version: link:../tools
birpc:
specifier: ^2.4.0
version: 2.4.0
+ pixelmatch:
+ specifier: ^7.1.0
+ version: 7.1.0
+ pngjs:
+ specifier: ^7.0.0
+ version: 7.0.0
+ ssim.js:
+ specifier: ^3.5.0
+ version: 3.5.0
tslib:
specifier: ^2.3.0
version: 2.8.1
@@ -206,6 +224,12 @@ importers:
specifier: ^8.18.2
version: 8.18.2
devDependencies:
+ '@types/pixelmatch':
+ specifier: ^5.2.6
+ version: 5.2.6
+ '@types/pngjs':
+ specifier: ^6.0.5
+ version: 6.0.5
'@types/ws':
specifier: ^8.18.1
version: 8.18.1
@@ -488,6 +512,19 @@ importers:
specifier: '*'
version: 0.82.1(@babel/core@7.27.4)(@react-native-community/cli@20.0.0(typescript@5.9.3))(@react-native/metro-config@0.82.1(@babel/core@7.27.4))(@types/react@19.1.13)(react@19.1.1)
+ packages/ui:
+ dependencies:
+ '@react-native-harness/runtime':
+ specifier: workspace:*
+ version: link:../runtime
+ tslib:
+ specifier: ^2.3.0
+ version: 2.8.1
+ devDependencies:
+ react-native:
+ specifier: '*'
+ version: 0.82.1(@babel/core@7.27.4)(@react-native-community/cli@20.0.0(typescript@5.9.3))(@react-native/metro-config@0.82.1(@babel/core@7.27.4))(@types/react@19.1.13)(react@19.1.1)
+
website:
dependencies:
'@callstack/rspress-preset':
@@ -2955,6 +2992,12 @@ packages:
'@types/parse-json@4.0.2':
resolution: {integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==}
+ '@types/pixelmatch@5.2.6':
+ resolution: {integrity: sha512-wC83uexE5KGuUODn6zkm9gMzTwdY5L0chiK+VrKcDfEjzxh1uadlWTvOmAbCpnM9zx/Ww3f8uKlYQVnO/TrqVg==}
+
+ '@types/pngjs@6.0.5':
+ resolution: {integrity: sha512-0k5eKfrA83JOZPppLtS2C7OUtyNAl2wKNxfyYl9Q5g9lPkgBl/9hNyAu6HuEH2J4XmIv2znEpkDd0SaZVxW6iQ==}
+
'@types/react@19.1.13':
resolution: {integrity: sha512-hHkbU/eoO3EG5/MZkuFSKmYqPbSVk5byPFa3e7y/8TybHiLMACgI8seVYlicwk7H5K/rI2px9xrQp/C+AUDTiQ==}
@@ -5102,6 +5145,10 @@ packages:
resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==}
hasBin: true
+ glob@10.5.0:
+ resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==}
+ hasBin: true
+
glob@6.0.4:
resolution: {integrity: sha512-MKZeRNyYZAVVVG1oZeLaWie1uweH40m9AZwIwxyPbTSX4hHrVYSzLg0Ro5Z5R7XKkIX+Cc6oD1rqeDJnwsB8/A==}
deprecated: Glob versions prior to v9 are no longer supported
@@ -6930,6 +6977,10 @@ packages:
resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==}
engines: {node: '>= 6'}
+ pixelmatch@7.1.0:
+ resolution: {integrity: sha512-1wrVzJ2STrpmONHKBy228LM1b84msXDUoAzVEl0R8Mz4Ce6EPr+IVtxm8+yvrqLYMHswREkjYFaMxnyGnaY3Ng==}
+ hasBin: true
+
pkg-dir@4.2.0:
resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==}
engines: {node: '>=8'}
@@ -7873,6 +7924,9 @@ packages:
sprintf-js@1.0.3:
resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==}
+ ssim.js@3.5.0:
+ resolution: {integrity: sha512-Aj6Jl2z6oDmgYFFbQqK7fght19bXdOxY7Tj03nF+03M9gCBAjeIiO8/PlEGMfKDwYpw4q6iBqVq2YuREorGg/g==}
+
stable@0.1.8:
resolution: {integrity: sha512-ji9qxRnOVfcuLDySj9qzhGSEFVobyt1kIOSkj1qZzYLzq7Tos/oUUWvotUPQLlrsidqsK6tBH89Bc9kL5zHA6w==}
deprecated: 'Modern JS already guarantees Array#sort() is a stable sort, so this library is deprecated. See the compatibility table on MDN: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort#browser_compatibility'
@@ -10015,14 +10069,14 @@ snapshots:
dependencies:
'@jest/fake-timers': 29.7.0
'@jest/types': 29.6.3
- '@types/node': 18.16.9
+ '@types/node': 20.19.25
jest-mock: 29.7.0
'@jest/environment@30.2.0':
dependencies:
'@jest/fake-timers': 30.2.0
'@jest/types': 30.2.0
- '@types/node': 18.16.9
+ '@types/node': 20.19.25
jest-mock: 30.2.0
'@jest/expect-utils@30.2.0':
@@ -10040,7 +10094,7 @@ snapshots:
dependencies:
'@jest/types': 29.6.3
'@sinonjs/fake-timers': 10.3.0
- '@types/node': 18.16.9
+ '@types/node': 20.19.25
jest-message-util: 29.7.0
jest-mock: 29.7.0
jest-util: 29.7.0
@@ -10049,7 +10103,7 @@ snapshots:
dependencies:
'@jest/types': 30.2.0
'@sinonjs/fake-timers': 13.0.5
- '@types/node': 18.16.9
+ '@types/node': 20.19.25
jest-message-util: 30.2.0
jest-mock: 30.2.0
jest-util: 30.2.0
@@ -10067,7 +10121,7 @@ snapshots:
'@jest/pattern@30.0.1':
dependencies:
- '@types/node': 18.16.9
+ '@types/node': 20.19.25
jest-regex-util: 30.0.1
'@jest/reporters@30.2.0':
@@ -10078,11 +10132,11 @@ snapshots:
'@jest/transform': 30.2.0
'@jest/types': 30.2.0
'@jridgewell/trace-mapping': 0.3.25
- '@types/node': 18.16.9
+ '@types/node': 20.19.25
chalk: 4.1.2
collect-v8-coverage: 1.0.2
exit-x: 0.2.2
- glob: 10.4.5
+ glob: 10.5.0
graceful-fs: 4.2.11
istanbul-lib-coverage: 3.2.2
istanbul-lib-instrument: 6.0.3
@@ -10178,7 +10232,7 @@ snapshots:
'@jest/schemas': 29.6.3
'@types/istanbul-lib-coverage': 2.0.6
'@types/istanbul-reports': 3.0.4
- '@types/node': 18.16.9
+ '@types/node': 20.19.25
'@types/yargs': 17.0.33
chalk: 4.1.2
@@ -10265,7 +10319,7 @@ snapshots:
'@modern-js/utils@2.68.2':
dependencies:
'@swc/helpers': 0.5.17
- caniuse-lite: 1.0.30001723
+ caniuse-lite: 1.0.30001755
lodash: 4.17.21
rslog: 1.2.6
@@ -10508,13 +10562,13 @@ snapshots:
'@nodelib/fs.scandir': 2.1.5
fastq: 1.19.1
- '@nx/detox@22.0.4(htxksnrdasuzrfoxdvkmoikuza)':
+ '@nx/detox@22.0.4(9b1a4b49f421cf013b90dafab0a49a31)':
dependencies:
'@nx/devkit': 22.0.4(nx@22.0.4(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.17))(@swc/types@0.1.23)(typescript@5.9.3))(@swc/core@1.5.29(@swc/helpers@0.5.17)))
'@nx/eslint': 22.0.4(@babel/traverse@7.27.4)(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.17))(@swc/types@0.1.23)(typescript@5.9.3))(@swc/core@1.5.29(@swc/helpers@0.5.17))(@zkochan/js-yaml@0.0.7)(eslint@9.29.0(jiti@2.4.2))(nx@22.0.4(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.17))(@swc/types@0.1.23)(typescript@5.9.3))(@swc/core@1.5.29(@swc/helpers@0.5.17)))
'@nx/jest': 22.0.4(@babel/traverse@7.27.4)(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.17))(@swc/types@0.1.23)(typescript@5.9.3))(@swc/core@1.5.29(@swc/helpers@0.5.17))(@types/node@20.19.25)(babel-plugin-macros@3.1.0)(nx@22.0.4(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.17))(@swc/types@0.1.23)(typescript@5.9.3))(@swc/core@1.5.29(@swc/helpers@0.5.17)))(ts-node@9.1.1(typescript@5.9.3))(typescript@5.9.3)
'@nx/js': 22.0.4(@babel/traverse@7.27.4)(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.17))(@swc/types@0.1.23)(typescript@5.9.3))(@swc/core@1.5.29(@swc/helpers@0.5.17))(nx@22.0.4(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.17))(@swc/types@0.1.23)(typescript@5.9.3))(@swc/core@1.5.29(@swc/helpers@0.5.17)))
- '@nx/react': 22.0.4(@babel/core@7.27.4)(@babel/traverse@7.27.4)(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.17))(@swc/types@0.1.23)(typescript@5.9.3))(@swc/core@1.5.29(@swc/helpers@0.5.17))(@swc/helpers@0.5.17)(@types/babel__core@7.20.5)(@zkochan/js-yaml@0.0.7)(eslint@9.29.0(jiti@2.4.2))(nx@22.0.4(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.17))(@swc/types@0.1.23)(typescript@5.9.3))(@swc/core@1.5.29(@swc/helpers@0.5.17)))(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(ts-node@9.1.1(typescript@5.9.3))(typescript@5.9.3)(vite@7.2.2(@types/node@20.19.25)(jiti@2.4.2)(less@4.1.3)(lightningcss@1.27.0)(sass-embedded@1.89.2)(sass@1.89.2)(stylus@0.64.0)(terser@5.42.0)(yaml@2.8.0))(vitest@3.2.4(@types/debug@4.1.12)(@types/node@20.19.25)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@22.1.0)(less@4.1.3)(lightningcss@1.27.0)(sass-embedded@1.89.2)(sass@1.89.2)(stylus@0.64.0)(terser@5.42.0)(yaml@2.8.0))(webpack@5.102.1(@swc/core@1.5.29(@swc/helpers@0.5.17)))
+ '@nx/react': 22.0.4(@babel/core@7.27.4)(@babel/traverse@7.27.4)(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.17))(@swc/types@0.1.23)(typescript@5.9.3))(@swc/core@1.5.29(@swc/helpers@0.5.17))(@swc/helpers@0.5.17)(@types/babel__core@7.20.5)(@zkochan/js-yaml@0.0.7)(eslint@9.29.0(jiti@2.4.2))(nx@22.0.4(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.17))(@swc/types@0.1.23)(typescript@5.9.3))(@swc/core@1.5.29(@swc/helpers@0.5.17)))(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(ts-node@9.1.1(typescript@5.9.3))(typescript@5.9.3)(vite@7.2.2(@types/node@20.19.25)(jiti@2.4.2)(less@4.1.3)(lightningcss@1.27.0)(sass-embedded@1.89.2)(sass@1.89.2)(stylus@0.64.0)(terser@5.42.0)(yaml@2.8.0))(vitest@3.2.4)(webpack@5.102.1(@swc/core@1.5.29(@swc/helpers@0.5.17)))
detox: 20.46.0(@jest/environment@30.2.0)(@jest/types@30.2.0)(expect@30.2.0)(jest-environment-node@30.2.0)(jest@30.2.0(@types/node@20.19.25)(babel-plugin-macros@3.1.0)(ts-node@9.1.1(typescript@5.9.3)))
tslib: 2.8.1
transitivePeerDependencies:
@@ -10740,12 +10794,12 @@ snapshots:
'@nx/nx-win32-x64-msvc@22.0.4':
optional: true
- '@nx/react-native@22.0.4(j5mvkpom3exmbpfctoxl2tygky)':
+ '@nx/react-native@22.0.4(b6bdbf441fca35f932c3e030670387b0)':
dependencies:
'@nx/devkit': 22.0.4(nx@22.0.4(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.17))(@swc/types@0.1.23)(typescript@5.9.3))(@swc/core@1.5.29(@swc/helpers@0.5.17)))
'@nx/eslint': 22.0.4(@babel/traverse@7.27.4)(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.17))(@swc/types@0.1.23)(typescript@5.9.3))(@swc/core@1.5.29(@swc/helpers@0.5.17))(@zkochan/js-yaml@0.0.7)(eslint@9.29.0(jiti@2.4.2))(nx@22.0.4(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.17))(@swc/types@0.1.23)(typescript@5.9.3))(@swc/core@1.5.29(@swc/helpers@0.5.17)))
'@nx/js': 22.0.4(@babel/traverse@7.27.4)(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.17))(@swc/types@0.1.23)(typescript@5.9.3))(@swc/core@1.5.29(@swc/helpers@0.5.17))(nx@22.0.4(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.17))(@swc/types@0.1.23)(typescript@5.9.3))(@swc/core@1.5.29(@swc/helpers@0.5.17)))
- '@nx/react': 22.0.4(@babel/core@7.27.4)(@babel/traverse@7.27.4)(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.17))(@swc/types@0.1.23)(typescript@5.9.3))(@swc/core@1.5.29(@swc/helpers@0.5.17))(@swc/helpers@0.5.17)(@types/babel__core@7.20.5)(@zkochan/js-yaml@0.0.7)(eslint@9.29.0(jiti@2.4.2))(nx@22.0.4(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.17))(@swc/types@0.1.23)(typescript@5.9.3))(@swc/core@1.5.29(@swc/helpers@0.5.17)))(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(ts-node@9.1.1(typescript@5.9.3))(typescript@5.9.3)(vite@7.2.2(@types/node@20.19.25)(jiti@2.4.2)(less@4.1.3)(lightningcss@1.27.0)(sass-embedded@1.89.2)(sass@1.89.2)(stylus@0.64.0)(terser@5.42.0)(yaml@2.8.0))(vitest@3.2.4(@types/debug@4.1.12)(@types/node@20.19.25)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@22.1.0)(less@4.1.3)(lightningcss@1.27.0)(sass-embedded@1.89.2)(sass@1.89.2)(stylus@0.64.0)(terser@5.42.0)(yaml@2.8.0))(webpack@5.102.1(@swc/core@1.5.29(@swc/helpers@0.5.17)))
+ '@nx/react': 22.0.4(@babel/core@7.27.4)(@babel/traverse@7.27.4)(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.17))(@swc/types@0.1.23)(typescript@5.9.3))(@swc/core@1.5.29(@swc/helpers@0.5.17))(@swc/helpers@0.5.17)(@types/babel__core@7.20.5)(@zkochan/js-yaml@0.0.7)(eslint@9.29.0(jiti@2.4.2))(nx@22.0.4(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.17))(@swc/types@0.1.23)(typescript@5.9.3))(@swc/core@1.5.29(@swc/helpers@0.5.17)))(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(ts-node@9.1.1(typescript@5.9.3))(typescript@5.9.3)(vite@7.2.2(@types/node@20.19.25)(jiti@2.4.2)(less@4.1.3)(lightningcss@1.27.0)(sass-embedded@1.89.2)(sass@1.89.2)(stylus@0.64.0)(terser@5.42.0)(yaml@2.8.0))(vitest@3.2.4)(webpack@5.102.1(@swc/core@1.5.29(@swc/helpers@0.5.17)))
ajv: 8.17.1
enhanced-resolve: 5.18.3
ignore: 5.3.2
@@ -10756,7 +10810,7 @@ snapshots:
tsconfig-paths: 4.2.0
tslib: 2.8.1
optionalDependencies:
- '@nx/detox': 22.0.4(htxksnrdasuzrfoxdvkmoikuza)
+ '@nx/detox': 22.0.4(9b1a4b49f421cf013b90dafab0a49a31)
'@nx/rollup': 22.0.4(@babel/core@7.27.4)(@babel/traverse@7.27.4)(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.17))(@swc/types@0.1.23)(typescript@5.9.3))(@swc/core@1.5.29(@swc/helpers@0.5.17))(@types/babel__core@7.20.5)(nx@22.0.4(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.17))(@swc/types@0.1.23)(typescript@5.9.3))(@swc/core@1.5.29(@swc/helpers@0.5.17)))(ts-node@9.1.1(typescript@5.9.3))(typescript@5.9.3)
transitivePeerDependencies:
- '@babel/core'
@@ -10791,7 +10845,7 @@ snapshots:
- webpack
- webpack-cli
- '@nx/react@22.0.4(@babel/core@7.27.4)(@babel/traverse@7.27.4)(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.17))(@swc/types@0.1.23)(typescript@5.9.3))(@swc/core@1.5.29(@swc/helpers@0.5.17))(@swc/helpers@0.5.17)(@types/babel__core@7.20.5)(@zkochan/js-yaml@0.0.7)(eslint@9.29.0(jiti@2.4.2))(nx@22.0.4(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.17))(@swc/types@0.1.23)(typescript@5.9.3))(@swc/core@1.5.29(@swc/helpers@0.5.17)))(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(ts-node@9.1.1(typescript@5.9.3))(typescript@5.9.3)(vite@7.2.2(@types/node@20.19.25)(jiti@2.4.2)(less@4.1.3)(lightningcss@1.27.0)(sass-embedded@1.89.2)(sass@1.89.2)(stylus@0.64.0)(terser@5.42.0)(yaml@2.8.0))(vitest@3.2.4(@types/debug@4.1.12)(@types/node@20.19.25)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@22.1.0)(less@4.1.3)(lightningcss@1.27.0)(sass-embedded@1.89.2)(sass@1.89.2)(stylus@0.64.0)(terser@5.42.0)(yaml@2.8.0))(webpack@5.102.1(@swc/core@1.5.29(@swc/helpers@0.5.17)))':
+ '@nx/react@22.0.4(@babel/core@7.27.4)(@babel/traverse@7.27.4)(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.17))(@swc/types@0.1.23)(typescript@5.9.3))(@swc/core@1.5.29(@swc/helpers@0.5.17))(@swc/helpers@0.5.17)(@types/babel__core@7.20.5)(@zkochan/js-yaml@0.0.7)(eslint@9.29.0(jiti@2.4.2))(nx@22.0.4(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.17))(@swc/types@0.1.23)(typescript@5.9.3))(@swc/core@1.5.29(@swc/helpers@0.5.17)))(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(ts-node@9.1.1(typescript@5.9.3))(typescript@5.9.3)(vite@7.2.2(@types/node@20.19.25)(jiti@2.4.2)(less@4.1.3)(lightningcss@1.27.0)(sass-embedded@1.89.2)(sass@1.89.2)(stylus@0.64.0)(terser@5.42.0)(yaml@2.8.0))(vitest@3.2.4)(webpack@5.102.1(@swc/core@1.5.29(@swc/helpers@0.5.17)))':
dependencies:
'@nx/devkit': 22.0.4(nx@22.0.4(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.17))(@swc/types@0.1.23)(typescript@5.9.3))(@swc/core@1.5.29(@swc/helpers@0.5.17)))
'@nx/eslint': 22.0.4(@babel/traverse@7.27.4)(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.17))(@swc/types@0.1.23)(typescript@5.9.3))(@swc/core@1.5.29(@swc/helpers@0.5.17))(@zkochan/js-yaml@0.0.7)(eslint@9.29.0(jiti@2.4.2))(nx@22.0.4(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.17))(@swc/types@0.1.23)(typescript@5.9.3))(@swc/core@1.5.29(@swc/helpers@0.5.17)))
@@ -10809,7 +10863,7 @@ snapshots:
semver: 7.7.2
tslib: 2.8.1
optionalDependencies:
- '@nx/vite': 22.0.4(@babel/traverse@7.27.4)(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.17))(@swc/types@0.1.23)(typescript@5.9.3))(@swc/core@1.5.29(@swc/helpers@0.5.17))(nx@22.0.4(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.17))(@swc/types@0.1.23)(typescript@5.9.3))(@swc/core@1.5.29(@swc/helpers@0.5.17)))(typescript@5.9.3)(vite@7.2.2(@types/node@20.19.25)(jiti@2.4.2)(less@4.1.3)(lightningcss@1.27.0)(sass-embedded@1.89.2)(sass@1.89.2)(stylus@0.64.0)(terser@5.42.0)(yaml@2.8.0))(vitest@3.2.4(@types/debug@4.1.12)(@types/node@20.19.25)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@22.1.0)(less@4.1.3)(lightningcss@1.27.0)(sass-embedded@1.89.2)(sass@1.89.2)(stylus@0.64.0)(terser@5.42.0)(yaml@2.8.0))
+ '@nx/vite': 22.0.4(@babel/traverse@7.27.4)(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.17))(@swc/types@0.1.23)(typescript@5.9.3))(@swc/core@1.5.29(@swc/helpers@0.5.17))(nx@22.0.4(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.17))(@swc/types@0.1.23)(typescript@5.9.3))(@swc/core@1.5.29(@swc/helpers@0.5.17)))(typescript@5.9.3)(vite@7.2.2(@types/node@20.19.25)(jiti@2.4.2)(less@4.1.3)(lightningcss@1.27.0)(sass-embedded@1.89.2)(sass@1.89.2)(stylus@0.64.0)(terser@5.42.0)(yaml@2.8.0))(vitest@3.2.4)
transitivePeerDependencies:
- '@babel/core'
- '@babel/traverse'
@@ -10870,7 +10924,7 @@ snapshots:
- typescript
- verdaccio
- '@nx/vite@22.0.4(@babel/traverse@7.27.4)(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.17))(@swc/types@0.1.23)(typescript@5.9.3))(@swc/core@1.5.29(@swc/helpers@0.5.17))(nx@22.0.4(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.17))(@swc/types@0.1.23)(typescript@5.9.3))(@swc/core@1.5.29(@swc/helpers@0.5.17)))(typescript@5.9.3)(vite@7.2.2(@types/node@20.19.25)(jiti@2.4.2)(less@4.1.3)(lightningcss@1.27.0)(sass-embedded@1.89.2)(sass@1.89.2)(stylus@0.64.0)(terser@5.42.0)(yaml@2.8.0))(vitest@3.2.4(@types/debug@4.1.12)(@types/node@20.19.25)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@22.1.0)(less@4.1.3)(lightningcss@1.27.0)(sass-embedded@1.89.2)(sass@1.89.2)(stylus@0.64.0)(terser@5.42.0)(yaml@2.8.0))':
+ '@nx/vite@22.0.4(@babel/traverse@7.27.4)(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.17))(@swc/types@0.1.23)(typescript@5.9.3))(@swc/core@1.5.29(@swc/helpers@0.5.17))(nx@22.0.4(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.17))(@swc/types@0.1.23)(typescript@5.9.3))(@swc/core@1.5.29(@swc/helpers@0.5.17)))(typescript@5.9.3)(vite@7.2.2(@types/node@20.19.25)(jiti@2.4.2)(less@4.1.3)(lightningcss@1.27.0)(sass-embedded@1.89.2)(sass@1.89.2)(stylus@0.64.0)(terser@5.42.0)(yaml@2.8.0))(vitest@3.2.4)':
dependencies:
'@nx/devkit': 22.0.4(nx@22.0.4(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.17))(@swc/types@0.1.23)(typescript@5.9.3))(@swc/core@1.5.29(@swc/helpers@0.5.17)))
'@nx/js': 22.0.4(@babel/traverse@7.27.4)(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.17))(@swc/types@0.1.23)(typescript@5.9.3))(@swc/core@1.5.29(@swc/helpers@0.5.17))(nx@22.0.4(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.17))(@swc/types@0.1.23)(typescript@5.9.3))(@swc/core@1.5.29(@swc/helpers@0.5.17)))
@@ -11968,16 +12022,16 @@ snapshots:
'@types/fs-extra@8.1.5':
dependencies:
- '@types/node': 18.16.9
+ '@types/node': 20.19.25
'@types/glob@7.2.0':
dependencies:
'@types/minimatch': 5.1.2
- '@types/node': 18.16.9
+ '@types/node': 20.19.25
'@types/graceful-fs@4.1.9':
dependencies:
- '@types/node': 18.16.9
+ '@types/node': 20.19.25
'@types/hast@3.0.4':
dependencies:
@@ -11985,7 +12039,7 @@ snapshots:
'@types/http-proxy@1.17.16':
dependencies:
- '@types/node': 18.16.9
+ '@types/node': 20.19.25
'@types/istanbul-lib-coverage@2.0.6': {}
@@ -12019,6 +12073,14 @@ snapshots:
'@types/parse-json@4.0.2': {}
+ '@types/pixelmatch@5.2.6':
+ dependencies:
+ '@types/node': 20.19.25
+
+ '@types/pngjs@6.0.5':
+ dependencies:
+ '@types/node': 20.19.25
+
'@types/react@19.1.13':
dependencies:
csstype: 3.1.3
@@ -12337,7 +12399,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
- '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@20.19.25)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@22.1.0)(less@4.1.3)(lightningcss@1.27.0)(sass-embedded@1.89.2)(sass@1.89.2)(stylus@0.64.0)(terser@5.42.0)(yaml@2.8.0))':
+ '@vitest/coverage-v8@3.2.4(vitest@3.2.4)':
dependencies:
'@ampproject/remapping': 2.3.0
'@bcoe/v8-coverage': 1.0.2
@@ -13099,8 +13161,8 @@ snapshots:
caniuse-api@3.0.0:
dependencies:
- browserslist: 4.25.0
- caniuse-lite: 1.0.30001723
+ browserslist: 4.28.0
+ caniuse-lite: 1.0.30001755
lodash.memoize: 4.1.2
lodash.uniq: 4.5.0
@@ -13160,7 +13222,7 @@ snapshots:
chrome-launcher@0.15.2:
dependencies:
- '@types/node': 18.16.9
+ '@types/node': 20.19.25
escape-string-regexp: 4.0.0
is-wsl: 2.2.0
lighthouse-logger: 1.4.2
@@ -13171,7 +13233,7 @@ snapshots:
chromium-edge-launcher@0.2.0:
dependencies:
- '@types/node': 18.16.9
+ '@types/node': 20.19.25
escape-string-regexp: 4.0.0
is-wsl: 2.2.0
lighthouse-logger: 1.4.2
@@ -13348,7 +13410,7 @@ snapshots:
core-js-compat@3.43.0:
dependencies:
- browserslist: 4.25.0
+ browserslist: 4.28.0
core-js@3.45.1: {}
@@ -14689,6 +14751,15 @@ snapshots:
package-json-from-dist: 1.0.1
path-scurry: 1.11.1
+ glob@10.5.0:
+ dependencies:
+ foreground-child: 3.3.1
+ jackspeak: 3.4.3
+ minimatch: 9.0.5
+ minipass: 7.1.2
+ package-json-from-dist: 1.0.1
+ path-scurry: 1.11.1
+
glob@6.0.4:
dependencies:
inflight: 1.0.6
@@ -15377,7 +15448,7 @@ snapshots:
'@jest/expect': 30.2.0
'@jest/test-result': 30.2.0
'@jest/types': 30.2.0
- '@types/node': 18.16.9
+ '@types/node': 20.19.25
chalk: 4.1.2
co: 4.6.0
dedent: 1.6.0(babel-plugin-macros@3.1.0)
@@ -15544,7 +15615,7 @@ snapshots:
'@jest/environment': 29.7.0
'@jest/fake-timers': 29.7.0
'@jest/types': 29.6.3
- '@types/node': 18.16.9
+ '@types/node': 20.19.25
jest-mock: 29.7.0
jest-util: 29.7.0
@@ -15553,7 +15624,7 @@ snapshots:
'@jest/environment': 30.2.0
'@jest/fake-timers': 30.2.0
'@jest/types': 30.2.0
- '@types/node': 18.16.9
+ '@types/node': 20.19.25
jest-mock: 30.2.0
jest-util: 30.2.0
jest-validate: 30.2.0
@@ -15564,7 +15635,7 @@ snapshots:
dependencies:
'@jest/types': 29.6.3
'@types/graceful-fs': 4.1.9
- '@types/node': 18.16.9
+ '@types/node': 20.19.25
anymatch: 3.1.3
fb-watchman: 2.0.2
graceful-fs: 4.2.11
@@ -15579,7 +15650,7 @@ snapshots:
jest-haste-map@30.2.0:
dependencies:
'@jest/types': 30.2.0
- '@types/node': 18.16.9
+ '@types/node': 20.19.25
anymatch: 3.1.3
fb-watchman: 2.0.2
graceful-fs: 4.2.11
@@ -15630,13 +15701,13 @@ snapshots:
jest-mock@29.7.0:
dependencies:
'@jest/types': 29.6.3
- '@types/node': 18.16.9
+ '@types/node': 20.19.25
jest-util: 29.7.0
jest-mock@30.2.0:
dependencies:
'@jest/types': 30.2.0
- '@types/node': 18.16.9
+ '@types/node': 20.19.25
jest-util: 30.2.0
jest-pnp-resolver@1.2.3(jest-resolve@30.2.0):
@@ -15701,7 +15772,7 @@ snapshots:
'@jest/test-result': 30.2.0
'@jest/transform': 30.2.0
'@jest/types': 30.2.0
- '@types/node': 18.16.9
+ '@types/node': 20.19.25
chalk: 4.1.2
cjs-module-lexer: 2.1.0
collect-v8-coverage: 1.0.2
@@ -15748,7 +15819,7 @@ snapshots:
jest-util@29.7.0:
dependencies:
'@jest/types': 29.6.3
- '@types/node': 18.16.9
+ '@types/node': 20.19.25
chalk: 4.1.2
ci-info: 3.9.0
graceful-fs: 4.2.11
@@ -15785,7 +15856,7 @@ snapshots:
dependencies:
'@jest/test-result': 30.2.0
'@jest/types': 30.2.0
- '@types/node': 18.16.9
+ '@types/node': 20.19.25
ansi-escapes: 4.3.2
chalk: 4.1.2
emittery: 0.13.1
@@ -15794,7 +15865,7 @@ snapshots:
jest-worker@27.5.1:
dependencies:
- '@types/node': 18.16.9
+ '@types/node': 20.19.25
merge-stream: 2.0.0
supports-color: 8.1.1
@@ -15807,7 +15878,7 @@ snapshots:
jest-worker@30.2.0:
dependencies:
- '@types/node': 18.16.9
+ '@types/node': 20.19.25
'@ungap/structured-clone': 1.3.0
jest-util: 30.2.0
merge-stream: 2.0.0
@@ -17335,6 +17406,10 @@ snapshots:
pirates@4.0.7: {}
+ pixelmatch@7.1.0:
+ dependencies:
+ pngjs: 7.0.0
+
pkg-dir@4.2.0:
dependencies:
find-up: 4.1.0
@@ -17349,8 +17424,7 @@ snapshots:
dependencies:
find-up: 3.0.0
- pngjs@7.0.0:
- optional: true
+ pngjs@7.0.0: {}
portfinder@1.0.37:
dependencies:
@@ -17369,7 +17443,7 @@ snapshots:
postcss-colormin@5.3.1(postcss@8.5.6):
dependencies:
- browserslist: 4.25.0
+ browserslist: 4.28.0
caniuse-api: 3.0.0
colord: 2.9.3
postcss: 8.5.6
@@ -17377,7 +17451,7 @@ snapshots:
postcss-convert-values@5.1.3(postcss@8.5.6):
dependencies:
- browserslist: 4.25.0
+ browserslist: 4.28.0
postcss: 8.5.6
postcss-value-parser: 4.2.0
@@ -17421,7 +17495,7 @@ snapshots:
postcss-merge-rules@5.1.4(postcss@8.5.6):
dependencies:
- browserslist: 4.25.0
+ browserslist: 4.28.0
caniuse-api: 3.0.0
cssnano-utils: 3.1.0(postcss@8.5.6)
postcss: 8.5.6
@@ -17441,7 +17515,7 @@ snapshots:
postcss-minify-params@5.1.4(postcss@8.5.6):
dependencies:
- browserslist: 4.25.0
+ browserslist: 4.28.0
cssnano-utils: 3.1.0(postcss@8.5.6)
postcss: 8.5.6
postcss-value-parser: 4.2.0
@@ -17515,7 +17589,7 @@ snapshots:
postcss-normalize-unicode@5.1.1(postcss@8.5.6):
dependencies:
- browserslist: 4.25.0
+ browserslist: 4.28.0
postcss: 8.5.6
postcss-value-parser: 4.2.0
@@ -17538,7 +17612,7 @@ snapshots:
postcss-reduce-initial@5.1.2(postcss@8.5.6):
dependencies:
- browserslist: 4.25.0
+ browserslist: 4.28.0
caniuse-api: 3.0.0
postcss: 8.5.6
@@ -18419,6 +18493,8 @@ snapshots:
sprintf-js@1.0.3: {}
+ ssim.js@3.5.0: {}
+
stable@0.1.8: {}
stack-trace@0.0.10:
@@ -18588,7 +18664,7 @@ snapshots:
stylehacks@5.1.1(postcss@8.5.6):
dependencies:
- browserslist: 4.25.0
+ browserslist: 4.28.0
postcss: 8.5.6
postcss-selector-parser: 6.1.2
@@ -18596,7 +18672,7 @@ snapshots:
dependencies:
'@adobe/css-tools': 4.3.3
debug: 4.4.1
- glob: 10.4.5
+ glob: 10.5.0
sax: 1.4.1
source-map: 0.7.6
transitivePeerDependencies:
@@ -18703,7 +18779,7 @@ snapshots:
test-exclude@7.0.1:
dependencies:
'@istanbuljs/schema': 0.1.3
- glob: 10.4.5
+ glob: 10.5.0
minimatch: 9.0.5
text-hex@1.0.0:
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index fc3b0ff..63e305e 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -4,8 +4,10 @@ packages:
- website
onlyBuiltDependencies:
- '@apollo/protobufjs'
+ - '@parcel/watcher'
- '@swc/core'
- appium
+ - core-js
- detox
- dtrace-provider
- edgedriver
@@ -15,3 +17,4 @@ onlyBuiltDependencies:
- nx
- sharp
- sqlite3
+ - unrs-resolver
diff --git a/tsconfig.json b/tsconfig.json
index a4d2764..c3aa2fe 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -53,6 +53,9 @@
},
{
"path": "./packages/github-action"
+ },
+ {
+ "path": "./packages/ui"
}
]
}
diff --git a/website/src/docs/api/rendering-components.md b/website/src/docs/api/rendering-components.md
index 62820a0..2915f3b 100644
--- a/website/src/docs/api/rendering-components.md
+++ b/website/src/docs/api/rendering-components.md
@@ -107,8 +107,7 @@ While Harness's render API is inspired by React Native Testing Library, there ar
### Differences
-- **No query functions**: Harness does not provide `getByText`, `findByRole`, etc. as it focuses on testing native behavior rather than component structure
-- **No user interaction utilities**: No `fireEvent` or user interaction helpers
+- **Query functions and user interaction utilities**: Available through the separate [`@react-native-harness/ui` package](/docs/guides/ui-testing) which provides `screen.findByTestId()`, `screen.findByAccessibilityLabel()`, `userEvent.press()`, `userEvent.type()`, and other utilities
- **Visual rendering**: Components are rendered as an overlay on the actual device/simulator, not in-memory
- **Enforces single component**: Only one component can be visible at a time
diff --git a/website/src/docs/getting-started/configuration.mdx b/website/src/docs/getting-started/configuration.mdx
index 9beb81e..cbcb9e5 100644
--- a/website/src/docs/getting-started/configuration.mdx
+++ b/website/src/docs/getting-started/configuration.mdx
@@ -1,3 +1,5 @@
+import { PackageManagerTabs } from '@theme';
+
# Configuration
React Native Harness can be configured through a configuration object that defines various aspects of your testing setup.
@@ -112,9 +114,7 @@ A test runner defines how tests are executed on a specific platform. React Nativ
Install the Android platform package:
-```bash
-npm install @react-native-harness/platform-android
-```
+
**Usage:**
@@ -144,9 +144,7 @@ androidPlatform({
Install the Apple platform package:
-```bash
-npm install @react-native-harness/platform-apple
-```
+
**Usage:**
@@ -176,9 +174,7 @@ applePlatform({
Install the Vega platform package:
-```bash
-npm install @react-native-harness/platform-vega
-```
+
**Usage:**
diff --git a/website/src/docs/guides/_meta.json b/website/src/docs/guides/_meta.json
new file mode 100644
index 0000000..017b5f0
--- /dev/null
+++ b/website/src/docs/guides/_meta.json
@@ -0,0 +1,17 @@
+[
+ {
+ "type": "file",
+ "name": "ui-testing",
+ "label": "UI Testing"
+ },
+ {
+ "type": "file",
+ "name": "ci-cd",
+ "label": "CI/CD Integration"
+ },
+ {
+ "type": "file",
+ "name": "migration-guide",
+ "label": "Migration Guide"
+ }
+]
diff --git a/website/src/docs/guides/index.md b/website/src/docs/guides/index.md
index 288db72..0d8541c 100644
--- a/website/src/docs/guides/index.md
+++ b/website/src/docs/guides/index.md
@@ -4,6 +4,7 @@ This section contains comprehensive guides for advanced React Native Harness usa
## Available Guides
+- [**UI Testing**](./ui-testing.md) - Learn how to perform visual testing and element interaction testing with React Native Harness
- [**Migration Guide**](./migration-guide.md) - Learn how to migrate from the old CLI-based workflow to the new Jest-based workflow
- [**CI/CD Integration**](./ci-cd.md) - Learn how to run React Native Harness tests in GitHub Actions and other CI/CD environments
diff --git a/website/src/docs/guides/ui-testing.md b/website/src/docs/guides/ui-testing.md
new file mode 100644
index 0000000..82358a4
--- /dev/null
+++ b/website/src/docs/guides/ui-testing.md
@@ -0,0 +1,231 @@
+import { PackageManagerTabs } from '@theme';
+
+# UI Testing
+
+React Native Harness provides powerful UI testing capabilities that allow you to test your React Native components visually through screenshot testing and element interaction.
+
+:::warning
+UI testing is an **opt-in feature** that requires installing an additional package. The `@react-native-harness/ui` package is not included by default and must be explicitly installed to use these features.
+
+This package contains **native code** and requires rebuilding your app after installation. Additionally, this code is **automatically excluded from release builds** and is only available in debug builds.
+:::
+
+## Installation
+
+To enable UI testing in your project, install the UI testing package:
+
+
+
+After installation, **rebuild your app** since this package contains native code that needs to be compiled and linked.
+
+## Screen API
+
+The `screen` object provides methods to query and interact with UI elements in your React Native application. It uses accessibility properties and test IDs to locate elements.
+
+### Element queries
+
+```typescript
+import { describe, test, render, expect } from 'react-native-harness';
+import { screen } from '@react-native-harness/ui';
+import { View, Text } from 'react-native';
+
+describe('Element Queries', () => {
+ test('find elements by testID', async () => {
+ await render(
+
+ Welcome!
+ This is a test
+
+ );
+
+ // These methods throw if no element is found
+ const welcomeText = screen.findByTestId('welcome-text');
+ const subtitle = screen.findByTestId('subtitle');
+
+ // These methods return null if no element is found
+ const welcomeTextSafe = screen.queryByTestId('welcome-text');
+ const allSubtitles = screen.queryAllByTestId('subtitle');
+ });
+
+ test('find elements by accessibility label', async () => {
+ await render(
+
+ Hello World
+
+ );
+
+ const heading = screen.findByAccessibilityLabel('Main heading');
+ });
+});
+```
+
+### Available query methods
+
+| Method | Description |
+|--------|-------------|
+| `findByTestId(testId)` | Find single element by testID (throws if not found) |
+| `findAllByTestId(testId)` | Find all elements by testID (throws if none found) |
+| `queryByTestId(testId)` | Find single element by testID (returns null if not found) |
+| `queryAllByTestId(testId)` | Find all elements by testID (returns empty array if none found) |
+| `findByAccessibilityLabel(label)` | Find single element by accessibility label (throws if not found) |
+| `findAllByAccessibilityLabel(label)` | Find all elements by accessibility label (throws if none found) |
+| `queryByAccessibilityLabel(label)` | Find single element by accessibility label (returns null if not found) |
+| `queryAllByAccessibilityLabel(label)` | Find all elements by accessibility label (returns empty array if none found) |
+
+## Screenshot testing
+
+Screenshot testing allows you to capture and compare visual snapshots of your UI components. This is particularly useful for detecting unexpected visual changes in your components.
+
+### Basic screenshot testing
+
+```typescript
+import { describe, test, render, expect } from 'react-native-harness';
+import { screen } from '@react-native-harness/ui';
+import { View, Text } from 'react-native';
+
+describe('Screenshot Testing', () => {
+ test('should match visual snapshot', async () => {
+ await render(
+
+
+ Hello, world!
+
+
+ );
+
+ const screenshot = await screen.screenshot();
+ await expect(screenshot).toMatchImageSnapshot({ name: 'blue-square' });
+ });
+});
+```
+
+### Screenshot options
+
+The `toMatchImageSnapshot` matcher accepts various options to customize the comparison:
+
+```typescript
+test('screenshot with custom options', async () => {
+ await render(/* your component */);
+ const screenshot = await screen.screenshot();
+
+ await expect(screenshot).toMatchImageSnapshot({
+ name: 'custom-screenshot',
+ threshold: 0.05, // More sensitive comparison (0-1)
+ failureThreshold: 0.01, // Minimum difference to trigger failure
+ failureThresholdType: 'percent', // 'pixel' or 'percent'
+ comparisonMethod: 'ssim', // 'pixelmatch' or 'ssim'
+ ssimThreshold: 0.95, // SSIM similarity threshold (0-1)
+ diffColor: [255, 0, 0], // RGB color for diff visualization
+ ignoreRegions: [
+ { x: 10, y: 10, width: 50, height: 50 }, // Ignore specific regions
+ ],
+ });
+});
+```
+
+### Screenshot options reference
+
+| Option | Type | Default | Description |
+|--------|------|---------|-------------|
+| `name` | `string` | - | **Required.** Unique name for the snapshot |
+| `threshold` | `number` | `0.1` | Matching threshold for pixelmatch (0-1). Smaller values are more sensitive |
+| `failureThreshold` | `number` | - | Minimum difference required to fail the test |
+| `failureThresholdType` | `'pixel' \| 'percent'` | `'pixel'` | How to interpret the failure threshold |
+| `comparisonMethod` | `'pixelmatch' \| 'ssim'` | `'pixelmatch'` | Algorithm used for image comparison |
+| `ssimThreshold` | `number` | `0.95` | Minimum similarity for SSIM comparison (0-1) |
+| `diffColor` | `[number, number, number]` | `[255, 0, 0]` | RGB color for highlighting differences |
+| `ignoreRegions` | `Array<{x,y,width,height}>` | `[]` | Regions to exclude from comparison |
+
+### Element-specific screenshots
+
+You can capture screenshots of specific elements instead of the entire screen:
+
+```typescript
+test('capture specific element', async () => {
+ await render(
+
+
+ Header
+
+
+ Content
+
+
+ );
+
+ const headerElement = screen.findByTestId('header');
+ const headerScreenshot = await screen.screenshot(headerElement);
+
+ await expect(headerScreenshot).toMatchImageSnapshot({
+ name: 'header-element'
+ });
+});
+```
+
+### Multiple screenshots in one test
+
+```typescript
+test('multiple screenshots', async () => {
+ // Initial render
+ await render();
+ const screenshot1 = await screen.screenshot();
+ await expect(screenshot1).toMatchImageSnapshot({ name: 'blue-state' });
+
+ // Update component
+ await render();
+ const screenshot2 = await screen.screenshot();
+ await expect(screenshot2).toMatchImageSnapshot({ name: 'red-state' });
+});
+```
+
+## User interaction testing
+
+The `userEvent` API allows you to simulate user interactions with your components:
+
+```typescript
+import { describe, test, render, expect, fn } from 'react-native-harness';
+import { screen, userEvent } from '@react-native-harness/ui';
+
+describe('User Interactions', () => {
+ test('button press', async () => {
+ const onPress = fn();
+
+ await render(
+
+ Press me
+
+ );
+
+ const button = screen.findByTestId('my-button');
+ await userEvent.press(button);
+
+ expect(onPress).toHaveBeenCalled();
+ });
+
+ test('text input', async () => {
+ await render();
+
+ const input = screen.findByTestId('username-input');
+ await userEvent.type(input, 'testuser');
+
+ // Verify the input value
+ expect(input.props.value).toBe('testuser');
+ });
+});
+```
+
+## Troubleshooting
+
+### Anti-aliased text differences
+Small differences in text rendering can cause false positives. Increase the `threshold` value for less sensitive comparison.
+
+### Dynamic content
+For components with dynamic content (dates, random values), use `ignoreRegions` to exclude those areas from comparison.
\ No newline at end of file