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/src/__tests__/ui/__image_snapshots__/blue-square.png b/apps/playground/src/__tests__/ui/__image_snapshots__/blue-square.png
new file mode 100644
index 0000000..0e09b97
Binary files /dev/null and b/apps/playground/src/__tests__/ui/__image_snapshots__/blue-square.png differ
diff --git a/apps/playground/src/__tests__/ui/__image_snapshots__/green-square.png b/apps/playground/src/__tests__/ui/__image_snapshots__/green-square.png
new file mode 100644
index 0000000..6d7fe62
Binary files /dev/null and b/apps/playground/src/__tests__/ui/__image_snapshots__/green-square.png differ
diff --git a/apps/playground/src/__tests__/ui/__image_snapshots__/purple-square-will-fail.png b/apps/playground/src/__tests__/ui/__image_snapshots__/purple-square-will-fail.png
new file mode 100644
index 0000000..390e07b
Binary files /dev/null and b/apps/playground/src/__tests__/ui/__image_snapshots__/purple-square-will-fail.png differ
diff --git a/apps/playground/src/__tests__/ui/__image_snapshots__/red-square.png b/apps/playground/src/__tests__/ui/__image_snapshots__/red-square.png
new file mode 100644
index 0000000..5080948
Binary files /dev/null and b/apps/playground/src/__tests__/ui/__image_snapshots__/red-square.png differ
diff --git a/apps/playground/src/__tests__/ui/__image_snapshots__/yellow-square-custom-options.png b/apps/playground/src/__tests__/ui/__image_snapshots__/yellow-square-custom-options.png
new file mode 100644
index 0000000..947a914
Binary files /dev/null and b/apps/playground/src/__tests__/ui/__image_snapshots__/yellow-square-custom-options.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..2533463
--- /dev/null
+++ b/apps/playground/src/__tests__/ui/actions.harness.tsx
@@ -0,0 +1,40 @@
+import {
+ screen,
+ describe,
+ test,
+ render,
+ userEvent,
+ fn,
+ expect,
+} from 'react-native-harness';
+import { View, Text, Pressable } from 'react-native';
+
+describe('Actions', () => {
+ test('should tap 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.tap(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..4b6c755
--- /dev/null
+++ b/apps/playground/src/__tests__/ui/queries.harness.tsx
@@ -0,0 +1,41 @@
+import { View, Text } from 'react-native';
+import {
+ describe,
+ test,
+ expect,
+ render,
+ screen,
+ userEvent,
+} from 'react-native-harness';
+
+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();
+ expect(element.id).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..6238b38
--- /dev/null
+++ b/apps/playground/src/__tests__/ui/screenshot.harness.tsx
@@ -0,0 +1,109 @@
+import { describe, test, render, screen, expect } from 'react-native-harness';
+import { View, Text } from 'react-native';
+
+describe('Screenshot', () => {
+ test('should match image snapshot with multiple snapshots', async () => {
+ await render(
+
+
+ Hello, world!
+
+
+ );
+ const screenshot1 = await screen.screenshot();
+ await expect(screenshot1).toMatchImageSnapshot({ name: 'blue-square' });
+
+ // Change the background color and take another snapshot
+ await render(
+
+
+ Hello, world!
+
+
+ );
+ const screenshot2 = await screen.screenshot();
+ await expect(screenshot2).toMatchImageSnapshot({ name: 'red-square' });
+
+ // Take a third snapshot with different content
+ await render(
+
+
+ Goodbye, world!
+
+
+ );
+ const screenshot3 = await screen.screenshot();
+ await expect(screenshot3).toMatchImageSnapshot({ name: 'green-square' });
+ });
+
+ test('should match image snapshot with custom options', async () => {
+ await render(
+
+
+ Custom options test
+
+
+ );
+ const screenshot = await screen.screenshot();
+ await expect(screenshot).toMatchImageSnapshot({
+ name: 'yellow-square-custom-options',
+ threshold: 0.05, // More sensitive threshold
+ diffColor: [0, 255, 0], // Green diff color
+ });
+ });
+
+ test('should create diff image when test fails', async () => {
+ await render(
+
+
+ This will fail
+
+
+ );
+ const screenshot = await screen.screenshot();
+ // This should fail and create a diff image
+ await expect(screenshot).toMatchImageSnapshot({
+ name: 'purple-square-will-fail',
+ });
+ });
+});
diff --git a/packages/bridge/package.json b/packages/bridge/package.json
index bfff8a7..642b8cf 100644
--- a/packages/bridge/package.json
+++ b/packages/bridge/package.json
@@ -27,12 +27,17 @@
}
},
"dependencies": {
+ "@react-native-harness/platforms": "workspace:*",
"@react-native-harness/tools": "workspace:*",
"birpc": "^2.4.0",
+ "pixelmatch": "^7.1.0",
+ "pngjs": "^7.0.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/image-snapshot.ts b/packages/bridge/src/image-snapshot.ts
new file mode 100644
index 0000000..b8aa159
--- /dev/null
+++ b/packages/bridge/src/image-snapshot.ts
@@ -0,0 +1,112 @@
+import pixelmatch from 'pixelmatch';
+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,
+};
+
+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}.`,
+ };
+ }
+
+ // Compare buffers byte by byte
+ const differences = pixelmatch(
+ img1.data,
+ img2.data,
+ diff.data,
+ width,
+ height,
+ pixelmatchOptions
+ );
+
+ const pass = differences === 0;
+
+ // 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);
+ }
+
+ return {
+ pass,
+ message: pass
+ ? 'Images match'
+ : `Images differ by ${differences} pixels. Diff saved at ${path.join(
+ snapshotsDir,
+ snapshotName.replace('.png', '') + '-diff.png'
+ )}. Actual image saved at ${path.join(
+ snapshotsDir,
+ snapshotName.replace('.png', '') + '-actual.png'
+ )}.`,
+ };
+};
diff --git a/packages/bridge/src/platform-bridge.ts b/packages/bridge/src/platform-bridge.ts
new file mode 100644
index 0000000..b83af28
--- /dev/null
+++ b/packages/bridge/src/platform-bridge.ts
@@ -0,0 +1,33 @@
+import type {
+ ElementReference,
+ HarnessPlatformRunner,
+} from '@react-native-harness/platforms';
+import type { BridgeServerFunctions } from './shared.js';
+
+export const createPlatformBridgeFunctions = (
+ platformRunner: HarnessPlatformRunner
+): Partial => {
+ return {
+ 'platform.actions.tap': async (x: number, y: number) => {
+ await platformRunner.actions.tap(x, y);
+ },
+ 'platform.actions.inputText': async (text: string) => {
+ await platformRunner.actions.inputText(text);
+ },
+ 'platform.actions.tapElement': async (element: ElementReference) => {
+ await platformRunner.actions.tapElement(element);
+ },
+ 'platform.actions.screenshot': async () => {
+ return await platformRunner.actions.screenshot();
+ },
+ 'platform.queries.getUiHierarchy': async () => {
+ return await platformRunner.queries.getUiHierarchy();
+ },
+ 'platform.queries.findByTestId': async (testId: string) => {
+ return await platformRunner.queries.findByTestId(testId);
+ },
+ 'platform.queries.findAllByTestId': async (testId: string) => {
+ return await platformRunner.queries.findAllByTestId(testId);
+ },
+ };
+};
diff --git a/packages/bridge/src/server.ts b/packages/bridge/src/server.ts
index 943a676..6da435d 100644
--- a/packages/bridge/src/server.ts
+++ b/packages/bridge/src/server.ts
@@ -7,13 +7,22 @@ import type {
BridgeClientFunctions,
DeviceDescriptor,
BridgeEvents,
+ ImageSnapshotOptions,
+ HarnessContext,
} from './shared.js';
import { deserialize, serialize } from './serializer.js';
import { DeviceNotRespondingError } from './errors.js';
+import { createPlatformBridgeFunctions } from './platform-bridge.js';
+import type {
+ FileReference,
+ HarnessPlatformRunner,
+} from '@react-native-harness/platforms';
+import { matchImageSnapshot } from './image-snapshot.js';
export type BridgeServerOptions = {
port: number;
timeout?: number;
+ context: HarnessContext;
};
export type BridgeServerEvents = {
@@ -36,12 +45,14 @@ export type BridgeServer = {
event: T,
listener: BridgeServerEvents[T]
) => void;
+ updatePlatformFunctions: (platformRunner: HarnessPlatformRunner) => void;
dispose: () => void;
};
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,24 +62,70 @@ export const getBridgeServer = async ({
const emitter = new EventEmitter();
const clients = new Set();
+ const baseFunctions: BridgeServerFunctions = {
+ reportReady: (device) => {
+ emitter.emit('ready', device);
+ },
+ emitEvent: (_, data) => {
+ emitter.emit('event', data);
+ },
+ 'platform.actions.tap': async () => {
+ throw new Error('Platform functions not initialized');
+ },
+ 'platform.actions.inputText': async () => {
+ throw new Error('Platform functions not initialized');
+ },
+ 'platform.actions.tapElement': async () => {
+ throw new Error('Platform functions not initialized');
+ },
+ 'platform.queries.getUiHierarchy': async () => {
+ throw new Error('Platform functions not initialized');
+ },
+ 'platform.queries.findByTestId': async () => {
+ throw new Error('Platform functions not initialized');
+ },
+ 'platform.queries.findAllByTestId': async () => {
+ throw new Error('Platform functions not initialized');
+ },
+ 'platform.actions.screenshot': async () => {
+ throw new Error('Platform functions not initialized');
+ },
+ '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);
},
}
);
+ const updatePlatformFunctions = (
+ platformRunner: HarnessPlatformRunner
+ ): void => {
+ const platformFunctions = createPlatformBridgeFunctions(platformRunner);
+ Object.assign(baseFunctions, platformFunctions);
+ };
+
wss.on('connection', (ws: WebSocket) => {
logger.debug('Client connected to the bridge');
ws.on('close', () => {
@@ -104,6 +161,7 @@ export const getBridgeServer = async ({
on: emitter.on.bind(emitter),
once: emitter.once.bind(emitter),
off: emitter.off.bind(emitter),
+ updatePlatformFunctions,
dispose,
};
};
diff --git a/packages/bridge/src/shared.ts b/packages/bridge/src/shared.ts
index 86ed98a..c2ea838 100644
--- a/packages/bridge/src/shared.ts
+++ b/packages/bridge/src/shared.ts
@@ -4,6 +4,52 @@ import type {
} from './shared/test-runner.js';
import type { TestCollectorEvents } from './shared/test-collector.js';
import type { BundlerEvents } from './shared/bundler.js';
+import type {
+ UIElement,
+ ElementReference,
+ FileReference,
+ HarnessPlatform,
+} from '@react-native-harness/platforms';
+
+export type {
+ UIElement,
+ ElementReference,
+ FileReference,
+} from '@react-native-harness/platforms';
+
+export type ImageSnapshotOptions = {
+ /**
+ * The name of the snapshot. This is required and must be unique within the test.
+ */
+ name: string;
+ /**
+ * Matching threshold, ranges from 0 to 1. Smaller values make the comparison more sensitive.
+ * @default 0.1
+ */
+ threshold?: 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,
@@ -60,12 +106,13 @@ export type TestExecutionOptions = {
testNamePattern?: string;
setupFiles?: string[];
setupFilesAfterEnv?: string[];
+ runner: string;
};
export type BridgeClientFunctions = {
runTests: (
path: string,
- options?: TestExecutionOptions
+ options: TestExecutionOptions
) => Promise;
};
@@ -75,4 +122,25 @@ export type BridgeServerFunctions = {
event: TEvent['type'],
data: TEvent
) => void;
+ 'platform.actions.tap': (x: number, y: number) => Promise;
+ 'platform.actions.inputText': (text: string) => Promise;
+ 'platform.actions.tapElement': (element: ElementReference) => Promise;
+ 'platform.actions.screenshot': () => Promise;
+ 'platform.queries.getUiHierarchy': () => Promise;
+ 'platform.queries.findByTestId': (
+ testId: string
+ ) => Promise;
+ 'platform.queries.findAllByTestId': (
+ testId: string
+ ) => 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/jest/src/harness.ts b/packages/jest/src/harness.ts
index d7796c7..be98466 100644
--- a/packages/jest/src/harness.ts
+++ b/packages/jest/src/harness.ts
@@ -1,5 +1,9 @@
import { getBridgeServer } from '@react-native-harness/bridge/server';
-import { BridgeClientFunctions } from '@react-native-harness/bridge';
+import {
+ HarnessContext,
+ TestExecutionOptions,
+ TestSuiteResult,
+} from '@react-native-harness/bridge';
import { HarnessPlatform } from '@react-native-harness/platforms';
import { getMetroInstance } from '@react-native-harness/bundler-metro';
import { InitializationTimeoutError } from './errors.js';
@@ -8,8 +12,14 @@ import pRetry from 'p-retry';
const BRIDGE_READY_TIMEOUT = 10000;
+export type HarnessRunTestsOptions = Exclude;
+
export type Harness = {
- runTests: BridgeClientFunctions['runTests'];
+ context: HarnessContext;
+ runTests: (
+ path: string,
+ options: HarnessRunTestsOptions
+ ) => Promise;
restart: () => Promise;
dispose: () => Promise;
};
@@ -20,15 +30,23 @@ const getHarnessInternal = async (
projectRoot: string,
signal: AbortSignal
): Promise => {
+ const context: HarnessContext = {
+ platform,
+ };
+
const [metroInstance, platformInstance, serverBridge] = await Promise.all([
getMetroInstance({ projectRoot }, signal),
import(platform.runner).then((module) => module.default(platform.config)),
getBridgeServer({
port: 3001,
timeout: config.bridgeTimeout,
+ context,
}),
]);
+ // Wire up platform functions to bridge
+ serverBridge.updatePlatformFunctions(platformInstance);
+
const dispose = async () => {
await Promise.all([
serverBridge.dispose(),
@@ -73,6 +91,7 @@ const getHarnessInternal = async (
});
return {
+ context,
runTests: async (path, options) => {
const client = serverBridge.rpc.clients.at(-1);
@@ -80,7 +99,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/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/platform-android/package.json b/packages/platform-android/package.json
index 6b3b65d..06f2e36 100644
--- a/packages/platform-android/package.json
+++ b/packages/platform-android/package.json
@@ -19,7 +19,8 @@
"@react-native-harness/platforms": "workspace:*",
"@react-native-harness/tools": "workspace:*",
"zod": "^3.25.67",
- "tslib": "^2.3.0"
+ "tslib": "^2.3.0",
+ "fast-xml-parser": "^4.3.2"
},
"license": "MIT"
}
diff --git a/packages/platform-android/src/adb.ts b/packages/platform-android/src/adb.ts
index 5b395cc..f397fab 100644
--- a/packages/platform-android/src/adb.ts
+++ b/packages/platform-android/src/adb.ts
@@ -97,3 +97,50 @@ export const isBootCompleted = async (adbId: string): Promise => {
export const stopEmulator = async (adbId: string): Promise => {
await spawn('adb', ['-s', adbId, 'emu', 'kill']);
};
+
+export const getUiHierarchy = async (adbId: string): Promise => {
+ const dumpPath = '/data/local/tmp/uidump.xml';
+ await spawn('adb', ['-s', adbId, 'shell', 'uiautomator', 'dump', dumpPath]);
+ const { stdout } = await spawn('adb', [
+ '-s',
+ adbId,
+ 'shell',
+ 'cat',
+ dumpPath,
+ ]);
+ await spawn('adb', ['-s', adbId, 'shell', 'rm', dumpPath]);
+ return stdout;
+};
+
+export const tap = async (
+ adbId: string,
+ x: number,
+ y: number
+): Promise => {
+ await spawn('adb', [
+ '-s',
+ adbId,
+ 'shell',
+ 'input',
+ 'tap',
+ x.toString(),
+ y.toString(),
+ ]);
+};
+
+export const inputText = async (adbId: string, text: string): Promise => {
+ // ADB input text requires spaces to be escaped as %s
+ const escapedText = text.replace(/ /g, '%s');
+ await spawn('adb', ['-s', adbId, 'shell', 'input', 'text', escapedText]);
+};
+
+export const screenshot = async (
+ adbId: string,
+ destination: string
+): Promise => {
+ const deviceTempPath = '/data/local/tmp/screenshot.png';
+ await spawn('adb', ['-s', adbId, 'shell', 'screencap', '-p', deviceTempPath]);
+ await spawn('adb', ['-s', adbId, 'pull', deviceTempPath, destination]);
+ await spawn('adb', ['-s', adbId, 'shell', 'rm', deviceTempPath]);
+ return destination;
+};
diff --git a/packages/platform-android/src/runner.ts b/packages/platform-android/src/runner.ts
index 4b4775e..b5b4e8a 100644
--- a/packages/platform-android/src/runner.ts
+++ b/packages/platform-android/src/runner.ts
@@ -1,6 +1,7 @@
import {
DeviceNotFoundError,
AppNotInstalledError,
+ ElementReference,
HarnessPlatformRunner,
} from '@react-native-harness/platforms';
import {
@@ -9,7 +10,16 @@ import {
} from './config.js';
import { getAdbId } from './adb-id.js';
import * as adb from './adb.js';
-import { getDeviceName } from './utils.js';
+import {
+ getDeviceName,
+ parseUiHierarchy,
+ findByTestId,
+ findAllByTestId,
+ getElementByPath,
+} from './utils.js';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { randomUUID } from 'node:crypto';
const getAndroidRunner = async (
config: AndroidPlatformConfig
@@ -36,6 +46,11 @@ const getAndroidRunner = async (
adb.reversePort(adbId, 3001),
]);
+ const getUiHierarchy = async () => {
+ const xmlString = await adb.getUiHierarchy(adbId);
+ return parseUiHierarchy(xmlString);
+ };
+
return {
startApp: async () => {
await adb.startApp(
@@ -58,6 +73,51 @@ const getAndroidRunner = async (
dispose: async () => {
await adb.stopApp(adbId, parsedConfig.bundleId);
},
+ queries: {
+ getUiHierarchy,
+ findByTestId: async (testId: string) => {
+ return await findByTestId(getUiHierarchy, testId);
+ },
+ findAllByTestId: async (testId: string) => {
+ return await findAllByTestId(getUiHierarchy, testId);
+ },
+ },
+ actions: {
+ tap: async (x: number, y: number) => {
+ await adb.tap(adbId, x, y);
+ },
+ inputText: async (text: string) => {
+ await adb.inputText(adbId, text);
+ },
+ tapElement: async (element: ElementReference) => {
+ // Query hierarchy again to get current state
+ const hierarchy = await getUiHierarchy();
+
+ // Get element by path identifier
+ const uiElement = getElementByPath(hierarchy, element.id);
+
+ if (!uiElement) {
+ throw new Error(
+ `Element with identifier "${element.id}" not found in UI hierarchy. The element may have been removed or the UI may have changed.`
+ );
+ }
+
+ // Calculate center coordinates
+ const centerX = uiElement.rect.x + uiElement.rect.width / 2;
+ const centerY = uiElement.rect.y + uiElement.rect.height / 2;
+
+ // Tap at center
+ await adb.tap(adbId, centerX, centerY);
+ },
+ screenshot: async () => {
+ const tempPath = join(
+ tmpdir(),
+ `harness-screenshot-${randomUUID()}.png`
+ );
+ await adb.screenshot(adbId, tempPath);
+ return { path: tempPath };
+ },
+ },
};
};
diff --git a/packages/platform-android/src/utils.ts b/packages/platform-android/src/utils.ts
index 0ed11c0..2903a91 100644
--- a/packages/platform-android/src/utils.ts
+++ b/packages/platform-android/src/utils.ts
@@ -1,3 +1,8 @@
+import { XMLParser } from 'fast-xml-parser';
+import type {
+ ElementReference,
+ UIElement,
+} from '@react-native-harness/platforms';
import { isAndroidDeviceEmulator, type AndroidDevice } from './config.js';
export const getDeviceName = (device: AndroidDevice): string => {
@@ -7,3 +12,207 @@ export const getDeviceName = (device: AndroidDevice): string => {
return `${device.manufacturer} ${device.model}`;
};
+
+const parseBounds = (
+ bounds: string
+): { x: number; y: number; width: number; height: number } => {
+ // Bounds format: [x1,y1][x2,y2]
+ const match = bounds.match(/\[(\d+),(\d+)\]\[(\d+),(\d+)\]/);
+ if (!match) {
+ return { x: 0, y: 0, width: 0, height: 0 };
+ }
+ const x1 = parseInt(match[1], 10);
+ const y1 = parseInt(match[2], 10);
+ const x2 = parseInt(match[3], 10);
+ const y2 = parseInt(match[4], 10);
+ return {
+ x: Math.min(x1, x2),
+ y: Math.min(y1, y2),
+ width: Math.abs(x2 - x1),
+ height: Math.abs(y2 - y1),
+ };
+};
+
+type XmlNode = {
+ '@_class'?: string;
+ '@_resource-id'?: string;
+ '@_text'?: string;
+ '@_content-desc'?: string;
+ '@_bounds'?: string;
+ '@_clickable'?: string;
+ '@_enabled'?: string;
+ '@_focusable'?: string;
+ '@_focused'?: string;
+ '@_scrollable'?: string;
+ '@_long-clickable'?: string;
+ '@_password'?: string;
+ '@_selected'?: string;
+ '@_checkable'?: string;
+ '@_checked'?: string;
+ node?: XmlNode | XmlNode[];
+ [key: string]: unknown;
+};
+
+const convertXmlNodeToUIElement = (node: XmlNode): UIElement => {
+ const attributes: Record = {};
+ const children: UIElement[] = [];
+
+ // Extract all attributes
+ if (node['@_class']) attributes.class = node['@_class'];
+ if (node['@_resource-id']) attributes['resource-id'] = node['@_resource-id'];
+ if (node['@_text']) attributes.text = node['@_text'];
+ if (node['@_content-desc'])
+ attributes['content-desc'] = node['@_content-desc'];
+ if (node['@_bounds']) attributes.bounds = node['@_bounds'];
+ if (node['@_clickable']) attributes.clickable = node['@_clickable'];
+ if (node['@_enabled']) attributes.enabled = node['@_enabled'];
+ if (node['@_focusable']) attributes.focusable = node['@_focusable'];
+ if (node['@_focused']) attributes.focused = node['@_focused'];
+ if (node['@_scrollable']) attributes.scrollable = node['@_scrollable'];
+ if (node['@_long-clickable'])
+ attributes['long-clickable'] = node['@_long-clickable'];
+ if (node['@_password']) attributes.password = node['@_password'];
+ if (node['@_selected']) attributes.selected = node['@_selected'];
+ if (node['@_checkable']) attributes.checkable = node['@_checkable'];
+ if (node['@_checked']) attributes.checked = node['@_checked'];
+
+ // Copy all other attributes
+ Object.keys(node).forEach((key) => {
+ if (key.startsWith('@_') && !attributes[key.slice(2)]) {
+ attributes[key.slice(2)] = node[key];
+ }
+ });
+
+ // Process children
+ if (node.node && Array.isArray(node.node)) {
+ children.push(...node.node.map(convertXmlNodeToUIElement));
+ } else if (node.node) {
+ children.push(convertXmlNodeToUIElement(node.node));
+ }
+
+ const bounds = node['@_bounds']
+ ? parseBounds(node['@_bounds'])
+ : { x: 0, y: 0, width: 0, height: 0 };
+
+ return {
+ type: node['@_class'] || 'unknown',
+ id: node['@_resource-id'] || undefined,
+ text: node['@_text'] || node['@_content-desc'] || undefined,
+ rect: bounds,
+ children,
+ attributes,
+ };
+};
+
+export const parseUiHierarchy = (xmlString: string): UIElement => {
+ const parser = new XMLParser({
+ ignoreAttributes: false,
+ attributeNamePrefix: '@_',
+ textNodeName: '#text',
+ });
+
+ const parsed = parser.parse(xmlString);
+ const hierarchyNode = parsed.hierarchy?.node;
+
+ if (!hierarchyNode) {
+ throw new Error('Invalid UI hierarchy XML: missing hierarchy.node');
+ }
+
+ // Handle case where node might be an array (though typically it's a single root node)
+ const rootNode = Array.isArray(hierarchyNode)
+ ? hierarchyNode[0]
+ : hierarchyNode;
+
+ return convertXmlNodeToUIElement(rootNode);
+};
+
+/**
+ * Recursively search for elements matching the testID
+ */
+const findElementsByTestId = (
+ element: UIElement,
+ testId: string,
+ path: number[] = []
+): Array<{ element: UIElement; path: number[] }> => {
+ const results: Array<{ element: UIElement; path: number[] }> = [];
+
+ // Check if this element matches the testID
+ // In React Native, testID is typically stored in content-desc or as a testID attribute
+ const elementTestId = element.attributes['resource-id'];
+
+ console.log('elementTestId: ' + elementTestId + ', testId: ' + testId);
+
+ if (elementTestId === testId) {
+ results.push({ element, path });
+ }
+
+ // Recursively search children
+ element.children.forEach((child, index) => {
+ results.push(...findElementsByTestId(child, testId, [...path, index]));
+ });
+
+ return results;
+};
+
+/**
+ * Find a single element by testID. Throws if not found.
+ */
+export const findByTestId = async (
+ getUiHierarchy: () => Promise,
+ testId: string
+): Promise => {
+ const hierarchy = await getUiHierarchy();
+ const matches = findElementsByTestId(hierarchy, testId);
+
+ if (matches.length === 0) {
+ throw new Error(
+ `Unable to find an element with testID: ${testId}. This could happen because:\n` +
+ ` - The element is not currently rendered\n` +
+ ` - The testID prop is not set on the element\n` +
+ ` - The element is outside the visible viewport`
+ );
+ }
+
+ if (matches.length > 1) {
+ throw new Error(
+ `Found multiple elements with testID: ${testId}. Use findAllByTestId instead.`
+ );
+ }
+
+ // Encode path as identifier: "0.1.2" represents indices in the tree
+ return { id: matches[0].path.join('.') };
+};
+
+/**
+ * Find all elements by testID. Returns empty array if none found.
+ */
+export const findAllByTestId = async (
+ getUiHierarchy: () => Promise,
+ testId: string
+): Promise => {
+ const hierarchy = await getUiHierarchy();
+ const matches = findElementsByTestId(hierarchy, testId);
+
+ // Encode paths as identifiers
+ return matches.map((match) => ({ id: match.path.join('.') }));
+};
+
+/**
+ * Get element by path identifier (e.g., "0.1.2")
+ */
+export const getElementByPath = (
+ hierarchy: UIElement,
+ pathStr: string
+): UIElement | null => {
+ const path = pathStr.split('.').map((s) => parseInt(s, 10));
+ let current: UIElement = hierarchy;
+
+ for (const index of path) {
+ if (index < 0 || index >= current.children.length) {
+ return null;
+ }
+ current = current.children[index];
+ }
+
+ return current;
+};
diff --git a/packages/platform-ios/src/instance.ts b/packages/platform-ios/src/instance.ts
index abfeb61..69a4f7f 100644
--- a/packages/platform-ios/src/instance.ts
+++ b/packages/platform-ios/src/instance.ts
@@ -11,6 +11,9 @@ import {
import * as simctl from './xcrun/simctl.js';
import * as devicectl from './xcrun/devicectl.js';
import { getDeviceName } from './utils.js';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { randomUUID } from 'node:crypto';
export const getAppleSimulatorPlatformInstance = async (
config: ApplePlatformConfig
@@ -64,6 +67,36 @@ export const getAppleSimulatorPlatformInstance = async (
dispose: async () => {
await simctl.stopApp(udid, config.bundleId);
},
+ queries: {
+ getUiHierarchy: async () => {
+ throw new Error('Not implemented yet');
+ },
+ findByTestId: async () => {
+ throw new Error('Not implemented yet');
+ },
+ findAllByTestId: async () => {
+ throw new Error('Not implemented yet');
+ },
+ },
+ actions: {
+ tap: async () => {
+ throw new Error('Not implemented yet');
+ },
+ inputText: async () => {
+ throw new Error('Not implemented yet');
+ },
+ tapElement: async () => {
+ throw new Error('Not implemented yet');
+ },
+ screenshot: async () => {
+ const tempPath = join(
+ tmpdir(),
+ `harness-screenshot-${randomUUID()}.png`
+ );
+ await simctl.screenshot(udid, tempPath);
+ return { path: tempPath };
+ },
+ },
};
};
@@ -101,5 +134,30 @@ export const getApplePhysicalDevicePlatformInstance = async (
dispose: async () => {
await devicectl.stopApp(deviceId, config.bundleId);
},
+ queries: {
+ getUiHierarchy: async () => {
+ throw new Error('Not implemented yet');
+ },
+ findByTestId: async () => {
+ throw new Error('Not implemented yet');
+ },
+ findAllByTestId: async () => {
+ throw new Error('Not implemented yet');
+ },
+ },
+ actions: {
+ tap: async () => {
+ throw new Error('Not implemented yet');
+ },
+ inputText: async () => {
+ throw new Error('Not implemented yet');
+ },
+ tapElement: async () => {
+ throw new Error('Not implemented yet');
+ },
+ screenshot: async () => {
+ throw new Error('Screenshot is not supported on physical iOS devices');
+ },
+ },
};
};
diff --git a/packages/platform-ios/src/xcrun/simctl.ts b/packages/platform-ios/src/xcrun/simctl.ts
index fc36bb6..5a4ed9a 100644
--- a/packages/platform-ios/src/xcrun/simctl.ts
+++ b/packages/platform-ios/src/xcrun/simctl.ts
@@ -120,3 +120,11 @@ export const getSimulatorId = async (
return simulator?.udid ?? null;
};
+
+export const screenshot = async (
+ udid: string,
+ destination: string
+): Promise => {
+ await spawn('xcrun', ['simctl', 'io', udid, 'screenshot', destination]);
+ return destination;
+};
diff --git a/packages/platform-vega/src/runner.ts b/packages/platform-vega/src/runner.ts
index d93bb5c..6c6feaf 100644
--- a/packages/platform-vega/src/runner.ts
+++ b/packages/platform-vega/src/runner.ts
@@ -38,6 +38,31 @@ const getVegaRunner = async (
dispose: async () => {
await kepler.stopApp(deviceId, bundleId);
},
+ queries: {
+ getUiHierarchy: async () => {
+ throw new Error('Not implemented yet');
+ },
+ findByTestId: async () => {
+ throw new Error('Not implemented yet');
+ },
+ findAllByTestId: async () => {
+ throw new Error('Not implemented yet');
+ },
+ },
+ actions: {
+ tap: async () => {
+ throw new Error('Not implemented yet');
+ },
+ inputText: async () => {
+ throw new Error('Not implemented yet');
+ },
+ tapElement: async () => {
+ throw new Error('Not implemented yet');
+ },
+ screenshot: async () => {
+ throw new Error('Not implemented');
+ },
+ },
};
};
diff --git a/packages/platforms/src/index.ts b/packages/platforms/src/index.ts
index 79197e8..ad25b13 100644
--- a/packages/platforms/src/index.ts
+++ b/packages/platforms/src/index.ts
@@ -1,2 +1,10 @@
-export type { HarnessPlatform, HarnessPlatformRunner } from './types.js';
+export type {
+ HarnessPlatform,
+ HarnessPlatformRunner,
+ UIElement,
+ PlatformActions,
+ PlatformQueries,
+ ElementReference,
+ FileReference,
+} from './types.js';
export { AppNotInstalledError, DeviceNotFoundError } from './errors.js';
diff --git a/packages/platforms/src/types.ts b/packages/platforms/src/types.ts
index 05a124f..cc70f6e 100644
--- a/packages/platforms/src/types.ts
+++ b/packages/platforms/src/types.ts
@@ -1,8 +1,32 @@
+export type UIElement = {
+ type: string;
+ id?: string;
+ text?: string;
+ rect: { x: number; y: number; width: number; height: number };
+ children: UIElement[];
+ attributes: Record;
+};
+
+export type PlatformActions = {
+ tap: (x: number, y: number) => Promise;
+ inputText: (text: string) => Promise;
+ tapElement: (element: ElementReference) => Promise;
+ screenshot: () => Promise;
+};
+
+export type PlatformQueries = {
+ getUiHierarchy: () => Promise;
+ findByTestId: (testId: string) => Promise;
+ findAllByTestId: (testId: string) => Promise;
+};
+
export type HarnessPlatformRunner = {
startApp: () => Promise;
restartApp: () => Promise;
stopApp: () => Promise;
dispose: () => Promise;
+ actions: PlatformActions;
+ queries: PlatformQueries;
};
export type HarnessPlatform> = {
@@ -10,3 +34,11 @@ export type HarnessPlatform> = {
config: TConfig;
runner: string;
};
+
+export type ElementReference = {
+ id: string;
+};
+
+export type FileReference = {
+ path: string;
+};
diff --git a/packages/runtime/src/client/factory.ts b/packages/runtime/src/client/factory.ts
index 15d5e8b..29b0b5d 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,12 @@ export const getClient = async () => {
},
});
+ // Store client instance for use by screen and userEvent APIs
+ 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 +88,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..1d6f69c
--- /dev/null
+++ b/packages/runtime/src/expect/matchers/toMatchImageSnapshot.ts
@@ -0,0 +1,33 @@
+import { getClientInstance } from '../../client/store.js';
+import type { MatcherState } from '@vitest/expect';
+import type {
+ FileReference,
+ ImageSnapshotOptions,
+} from '@react-native-harness/bridge';
+import { getHarnessContext } from '../../runner/index.js';
+
+declare module '@vitest/expect' {
+ interface Matchers {
+ toMatchImageSnapshot(options: ImageSnapshotOptions): Promise;
+ }
+}
+
+export async function toMatchImageSnapshot(
+ this: MatcherState,
+ received: FileReference,
+ options: ImageSnapshotOptions
+): Promise<{ pass: boolean; message: () => string }> {
+ const client = getClientInstance();
+ const context = getHarnessContext();
+ const result = await client.rpc['test.matchImageSnapshot'](
+ received,
+ context.testFilePath,
+ options,
+ context.runner
+ );
+
+ return {
+ pass: result.pass,
+ message: () => result.message,
+ };
+}
diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts
index dc03137..5344cde 100644
--- a/packages/runtime/src/index.ts
+++ b/packages/runtime/src/index.ts
@@ -8,3 +8,6 @@ export * from './mocker/index.js';
export * from './namespace.js';
export * from './waitFor.js';
export * from './render/index.js';
+export { userEvent } from './userEvent/index.js';
+export type { ElementReference } from '@react-native-harness/bridge';
+export { screen } from './screen/index.js';
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/screen/index.ts b/packages/runtime/src/screen/index.ts
new file mode 100644
index 0000000..ff35a15
--- /dev/null
+++ b/packages/runtime/src/screen/index.ts
@@ -0,0 +1,30 @@
+import type {
+ ElementReference,
+ FileReference,
+} from '@react-native-harness/bridge';
+import { getClientInstance } from '../client/store.js';
+
+export type Screen = {
+ findByTestId: (testId: string) => Promise;
+ findAllByTestId: (testId: string) => Promise;
+ screenshot: (name?: string) => Promise;
+};
+
+const createScreen = (): Screen => {
+ return {
+ findByTestId: async (testId: string): Promise => {
+ const client = getClientInstance();
+ return await client.rpc['platform.queries.findByTestId'](testId);
+ },
+ findAllByTestId: async (testId: string): Promise => {
+ const client = getClientInstance();
+ return await client.rpc['platform.queries.findAllByTestId'](testId);
+ },
+ screenshot: async (): Promise => {
+ const client = getClientInstance();
+ return await client.rpc['platform.actions.screenshot']();
+ },
+ };
+};
+
+export const screen = createScreen();
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/runtime/src/userEvent/index.ts b/packages/runtime/src/userEvent/index.ts
new file mode 100644
index 0000000..ff18021
--- /dev/null
+++ b/packages/runtime/src/userEvent/index.ts
@@ -0,0 +1,23 @@
+import type { ElementReference } from '@react-native-harness/bridge';
+import { getClientInstance } from '../client/store.js';
+
+export type UserEvent = {
+ tap: (element: ElementReference) => Promise;
+};
+
+const createUserEvent = (): UserEvent => {
+ return {
+ tap: async (element: ElementReference): Promise => {
+ const client = getClientInstance();
+ await (
+ client.rpc as unknown as {
+ 'platform.actions.tapElement': (
+ element: ElementReference
+ ) => Promise;
+ }
+ )['platform.actions.tapElement'](element);
+ },
+ };
+};
+
+export const userEvent = createUserEvent();
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index ffe5384..18c1aa3 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -161,7 +161,7 @@ importers:
version: 0.82.1(@babel/core@7.27.4)
'@react-native/eslint-config':
specifier: 0.82.1
- version: 0.82.1(eslint@9.29.0(jiti@2.6.0))(jest@30.2.0(@types/node@18.16.9)(babel-plugin-macros@3.1.0)(ts-node@9.1.1(typescript@5.9.3)))(prettier@2.8.8)(typescript@5.9.3)
+ version: 0.82.1(eslint@9.29.0(jiti@2.6.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)))(prettier@2.8.8)(typescript@5.9.3)
'@react-native/metro-config':
specifier: 0.82.1
version: 0.82.1(@babel/core@7.27.4)
@@ -170,7 +170,7 @@ importers:
version: 0.82.1
jest:
specifier: ^30.2.0
- version: 30.2.0(@types/node@18.16.9)(babel-plugin-macros@3.1.0)(ts-node@9.1.1(typescript@5.9.3))
+ version: 30.2.0(@types/node@20.19.25)(babel-plugin-macros@3.1.0)(ts-node@9.1.1(typescript@5.9.3))
react-native-harness:
specifier: workspace:*
version: link:../../packages/react-native-harness
@@ -193,12 +193,21 @@ 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
tslib:
specifier: ^2.3.0
version: 2.8.1
@@ -206,6 +215,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
@@ -355,6 +370,9 @@ importers:
'@react-native-harness/tools':
specifier: workspace:*
version: link:../tools
+ fast-xml-parser:
+ specifier: ^4.3.2
+ version: 4.5.3
tslib:
specifier: ^2.3.0
version: 2.8.1
@@ -2964,6 +2982,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==}
@@ -6961,6 +6985,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'}
@@ -10064,14 +10092,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':
@@ -10089,7 +10117,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
@@ -10098,7 +10126,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
@@ -10116,7 +10144,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':
@@ -10127,7 +10155,7 @@ 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
@@ -10227,7 +10255,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
@@ -11290,7 +11318,7 @@ snapshots:
- supports-color
- utf-8-validate
- '@react-native/eslint-config@0.82.1(eslint@9.29.0(jiti@2.6.0))(jest@30.2.0(@types/node@18.16.9)(babel-plugin-macros@3.1.0)(ts-node@9.1.1(typescript@5.9.3)))(prettier@2.8.8)(typescript@5.9.3)':
+ '@react-native/eslint-config@0.82.1(eslint@9.29.0(jiti@2.6.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)))(prettier@2.8.8)(typescript@5.9.3)':
dependencies:
'@babel/core': 7.27.4
'@babel/eslint-parser': 7.28.5(@babel/core@7.27.4)(eslint@9.29.0(jiti@2.6.0))
@@ -11301,7 +11329,7 @@ snapshots:
eslint-config-prettier: 8.10.2(eslint@9.29.0(jiti@2.6.0))
eslint-plugin-eslint-comments: 3.2.0(eslint@9.29.0(jiti@2.6.0))
eslint-plugin-ft-flow: 2.0.3(@babel/eslint-parser@7.28.5(@babel/core@7.27.4)(eslint@9.29.0(jiti@2.6.0)))(eslint@9.29.0(jiti@2.6.0))
- eslint-plugin-jest: 29.1.0(@typescript-eslint/eslint-plugin@8.46.4(@typescript-eslint/parser@8.46.4(eslint@9.29.0(jiti@2.6.0))(typescript@5.9.3))(eslint@9.29.0(jiti@2.6.0))(typescript@5.9.3))(eslint@9.29.0(jiti@2.6.0))(jest@30.2.0(@types/node@18.16.9)(babel-plugin-macros@3.1.0)(ts-node@9.1.1(typescript@5.9.3)))(typescript@5.9.3)
+ eslint-plugin-jest: 29.1.0(@typescript-eslint/eslint-plugin@8.46.4(@typescript-eslint/parser@8.46.4(eslint@9.29.0(jiti@2.6.0))(typescript@5.9.3))(eslint@9.29.0(jiti@2.6.0))(typescript@5.9.3))(eslint@9.29.0(jiti@2.6.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)))(typescript@5.9.3)
eslint-plugin-react: 7.35.0(eslint@9.29.0(jiti@2.6.0))
eslint-plugin-react-hooks: 5.2.0(eslint@9.29.0(jiti@2.6.0))
eslint-plugin-react-native: 4.1.0(eslint@9.29.0(jiti@2.6.0))
@@ -12020,16 +12048,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:
@@ -12037,7 +12065,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': {}
@@ -12071,6 +12099,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
@@ -13216,7 +13252,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
@@ -13227,7 +13263,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
@@ -14119,13 +14155,13 @@ snapshots:
- eslint-import-resolver-webpack
- supports-color
- eslint-plugin-jest@29.1.0(@typescript-eslint/eslint-plugin@8.46.4(@typescript-eslint/parser@8.46.4(eslint@9.29.0(jiti@2.6.0))(typescript@5.9.3))(eslint@9.29.0(jiti@2.6.0))(typescript@5.9.3))(eslint@9.29.0(jiti@2.6.0))(jest@30.2.0(@types/node@18.16.9)(babel-plugin-macros@3.1.0)(ts-node@9.1.1(typescript@5.9.3)))(typescript@5.9.3):
+ eslint-plugin-jest@29.1.0(@typescript-eslint/eslint-plugin@8.46.4(@typescript-eslint/parser@8.46.4(eslint@9.29.0(jiti@2.6.0))(typescript@5.9.3))(eslint@9.29.0(jiti@2.6.0))(typescript@5.9.3))(eslint@9.29.0(jiti@2.6.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)))(typescript@5.9.3):
dependencies:
'@typescript-eslint/utils': 8.46.4(eslint@9.29.0(jiti@2.6.0))(typescript@5.9.3)
eslint: 9.29.0(jiti@2.6.0)
optionalDependencies:
'@typescript-eslint/eslint-plugin': 8.46.4(@typescript-eslint/parser@8.46.4(eslint@9.29.0(jiti@2.6.0))(typescript@5.9.3))(eslint@9.29.0(jiti@2.6.0))(typescript@5.9.3)
- jest: 30.2.0(@types/node@18.16.9)(babel-plugin-macros@3.1.0)(ts-node@9.1.1(typescript@5.9.3))
+ jest: 30.2.0(@types/node@20.19.25)(babel-plugin-macros@3.1.0)(ts-node@9.1.1(typescript@5.9.3))
transitivePeerDependencies:
- supports-color
- typescript
@@ -15442,7 +15478,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)
@@ -15462,25 +15498,6 @@ snapshots:
- babel-plugin-macros
- supports-color
- jest-cli@30.2.0(@types/node@18.16.9)(babel-plugin-macros@3.1.0)(ts-node@9.1.1(typescript@5.9.3)):
- dependencies:
- '@jest/core': 30.2.0(babel-plugin-macros@3.1.0)(ts-node@9.1.1(typescript@5.9.3))
- '@jest/test-result': 30.2.0
- '@jest/types': 30.2.0
- chalk: 4.1.2
- exit-x: 0.2.2
- import-local: 3.2.0
- jest-config: 30.2.0(@types/node@18.16.9)(babel-plugin-macros@3.1.0)(ts-node@9.1.1(typescript@5.9.3))
- jest-util: 30.2.0
- jest-validate: 30.2.0
- yargs: 17.7.2
- transitivePeerDependencies:
- - '@types/node'
- - babel-plugin-macros
- - esbuild-register
- - supports-color
- - ts-node
-
jest-cli@30.2.0(@types/node@20.19.25)(babel-plugin-macros@3.1.0)(ts-node@9.1.1(typescript@5.9.3)):
dependencies:
'@jest/core': 30.2.0(babel-plugin-macros@3.1.0)(ts-node@9.1.1(typescript@5.9.3))
@@ -15609,7 +15626,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
@@ -15618,7 +15635,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
@@ -15629,7 +15646,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
@@ -15644,7 +15661,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
@@ -15695,13 +15712,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):
@@ -15766,7 +15783,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
@@ -15813,7 +15830,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
@@ -15850,7 +15867,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
@@ -15859,7 +15876,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
@@ -15872,25 +15889,12 @@ 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
supports-color: 8.1.1
- jest@30.2.0(@types/node@18.16.9)(babel-plugin-macros@3.1.0)(ts-node@9.1.1(typescript@5.9.3)):
- dependencies:
- '@jest/core': 30.2.0(babel-plugin-macros@3.1.0)(ts-node@9.1.1(typescript@5.9.3))
- '@jest/types': 30.2.0
- import-local: 3.2.0
- jest-cli: 30.2.0(@types/node@18.16.9)(babel-plugin-macros@3.1.0)(ts-node@9.1.1(typescript@5.9.3))
- transitivePeerDependencies:
- - '@types/node'
- - babel-plugin-macros
- - esbuild-register
- - supports-color
- - ts-node
-
jest@30.2.0(@types/node@20.19.25)(babel-plugin-macros@3.1.0)(ts-node@9.1.1(typescript@5.9.3)):
dependencies:
'@jest/core': 30.2.0(babel-plugin-macros@3.1.0)(ts-node@9.1.1(typescript@5.9.3))
@@ -15903,7 +15907,6 @@ snapshots:
- esbuild-register
- supports-color
- ts-node
- optional: true
jiti@2.4.2: {}
@@ -17406,6 +17409,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
@@ -17420,8 +17427,7 @@ snapshots:
dependencies:
find-up: 3.0.0
- pngjs@7.0.0:
- optional: true
+ pngjs@7.0.0: {}
portfinder@1.0.37:
dependencies:
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