From 147871f903b5f1dc835ab66a0b4d015042b1021a Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Thu, 20 Nov 2025 14:01:16 +0100 Subject: [PATCH 1/3] feat: initial impl --- .../src/__tests__/ui/queries.harness.tsx | 55 +++++ packages/bridge/package.json | 1 + packages/bridge/src/platform-bridge.ts | 30 +++ packages/bridge/src/server.ts | 47 +++- packages/bridge/src/shared.ts | 19 ++ packages/bridge/tsconfig.json | 3 + packages/bridge/tsconfig.lib.json | 3 + packages/jest/src/harness.ts | 3 + packages/platform-android/package.json | 3 +- packages/platform-android/src/adb.ts | 36 +++ packages/platform-android/src/runner.ts | 51 ++++- packages/platform-android/src/utils.ts | 210 ++++++++++++++++++ packages/platform-ios/src/instance.ts | 44 ++++ packages/platform-vega/src/runner.ts | 22 ++ packages/platforms/src/index.ts | 9 +- packages/platforms/src/types.ts | 27 +++ packages/runtime/src/client/factory.ts | 4 + packages/runtime/src/client/store.ts | 16 ++ packages/runtime/src/index.ts | 3 + packages/runtime/src/screen/index.ts | 34 +++ packages/runtime/src/userEvent/index.ts | 23 ++ pnpm-lock.yaml | 6 + pnpm-workspace.yaml | 3 + 23 files changed, 641 insertions(+), 11 deletions(-) create mode 100644 apps/playground/src/__tests__/ui/queries.harness.tsx create mode 100644 packages/bridge/src/platform-bridge.ts create mode 100644 packages/runtime/src/client/store.ts create mode 100644 packages/runtime/src/screen/index.ts create mode 100644 packages/runtime/src/userEvent/index.ts 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..01c51ef --- /dev/null +++ b/apps/playground/src/__tests__/ui/queries.harness.tsx @@ -0,0 +1,55 @@ +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); + }); + + test('should tap element found by testID', async () => { + await render( + + + This is a view with a testID + + + ); + const element = await screen.findByTestId('this-is-test-id'); + await userEvent.tap(element); + // If tap succeeds without throwing, the test passes + expect(element).toBeDefined(); + }); +}); diff --git a/packages/bridge/package.json b/packages/bridge/package.json index bfff8a7..c385301 100644 --- a/packages/bridge/package.json +++ b/packages/bridge/package.json @@ -27,6 +27,7 @@ } }, "dependencies": { + "@react-native-harness/platforms": "workspace:*", "@react-native-harness/tools": "workspace:*", "birpc": "^2.4.0", "tslib": "^2.3.0", diff --git a/packages/bridge/src/platform-bridge.ts b/packages/bridge/src/platform-bridge.ts new file mode 100644 index 0000000..43cd73a --- /dev/null +++ b/packages/bridge/src/platform-bridge.ts @@ -0,0 +1,30 @@ +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.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..6416bec 100644 --- a/packages/bridge/src/server.ts +++ b/packages/bridge/src/server.ts @@ -10,6 +10,8 @@ import type { } from './shared.js'; import { deserialize, serialize } from './serializer.js'; import { DeviceNotRespondingError } from './errors.js'; +import { createPlatformBridgeFunctions } from './platform-bridge.js'; +import type { HarnessPlatformRunner } from '@react-native-harness/platforms'; export type BridgeServerOptions = { port: number; @@ -36,6 +38,7 @@ export type BridgeServer = { event: T, listener: BridgeServerEvents[T] ) => void; + updatePlatformFunctions: (platformRunner: HarnessPlatformRunner) => void; dispose: () => void; }; @@ -51,15 +54,35 @@ 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'); + }, + }; + const group = createBirpcGroup( - { - reportReady: (device) => { - emitter.emit('ready', device); - }, - emitEvent: (_, data) => { - emitter.emit('event', data); - }, - } satisfies BridgeServerFunctions, + baseFunctions, [], { timeout, @@ -69,6 +92,13 @@ export const getBridgeServer = async ({ } ); + 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 +134,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..9b58bd2 100644 --- a/packages/bridge/src/shared.ts +++ b/packages/bridge/src/shared.ts @@ -4,6 +4,15 @@ 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, +} from '@react-native-harness/platforms'; + +export type { + UIElement, + ElementReference, +} from '@react-native-harness/platforms'; export type { TestCollectorEvents, @@ -75,4 +84,14 @@ 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.queries.getUiHierarchy': () => Promise; + 'platform.queries.findByTestId': ( + testId: string + ) => Promise; + 'platform.queries.findAllByTestId': ( + testId: string + ) => Promise; }; 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..6b9561e 100644 --- a/packages/jest/src/harness.ts +++ b/packages/jest/src/harness.ts @@ -29,6 +29,9 @@ const getHarnessInternal = async ( }), ]); + // Wire up platform functions to bridge + serverBridge.updatePlatformFunctions(platformInstance); + const dispose = async () => { await Promise.all([ serverBridge.dispose(), 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..e2466ef 100644 --- a/packages/platform-android/src/adb.ts +++ b/packages/platform-android/src/adb.ts @@ -97,3 +97,39 @@ 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]); +}; diff --git a/packages/platform-android/src/runner.ts b/packages/platform-android/src/runner.ts index 4b4775e..6aa21ba 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,13 @@ 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'; const getAndroidRunner = async ( config: AndroidPlatformConfig @@ -36,6 +43,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 +70,43 @@ 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); + }, + }, }; }; diff --git a/packages/platform-android/src/utils.ts b/packages/platform-android/src/utils.ts index 0ed11c0..c7fd835 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,208 @@ 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['testID'] || + element.attributes['test-id'] || + element.attributes['content-desc']; + + 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..b485f5c 100644 --- a/packages/platform-ios/src/instance.ts +++ b/packages/platform-ios/src/instance.ts @@ -64,6 +64,28 @@ 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'); + }, + }, }; }; @@ -101,5 +123,27 @@ 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'); + }, + }, }; }; diff --git a/packages/platform-vega/src/runner.ts b/packages/platform-vega/src/runner.ts index d93bb5c..a8d77c3 100644 --- a/packages/platform-vega/src/runner.ts +++ b/packages/platform-vega/src/runner.ts @@ -38,6 +38,28 @@ 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'); + }, + }, }; }; diff --git a/packages/platforms/src/index.ts b/packages/platforms/src/index.ts index 79197e8..1444e33 100644 --- a/packages/platforms/src/index.ts +++ b/packages/platforms/src/index.ts @@ -1,2 +1,9 @@ -export type { HarnessPlatform, HarnessPlatformRunner } from './types.js'; +export type { + HarnessPlatform, + HarnessPlatformRunner, + UIElement, + PlatformActions, + PlatformQueries, + ElementReference, +} 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..e983425 100644 --- a/packages/platforms/src/types.ts +++ b/packages/platforms/src/types.ts @@ -1,8 +1,31 @@ +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; +}; + +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 +33,7 @@ export type HarnessPlatform> = { config: TConfig; runner: string; }; + +export type ElementReference = { + id: string; +}; diff --git a/packages/runtime/src/client/factory.ts b/packages/runtime/src/client/factory.ts index 15d5e8b..0914ad4 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,6 +23,9 @@ 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 = {} 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/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/screen/index.ts b/packages/runtime/src/screen/index.ts new file mode 100644 index 0000000..6f979e1 --- /dev/null +++ b/packages/runtime/src/screen/index.ts @@ -0,0 +1,34 @@ +import type { ElementReference } from '@react-native-harness/bridge'; +import { getClientInstance } from '../client/store.js'; + +export type Screen = { + findByTestId: (testId: string) => Promise; + findAllByTestId: (testId: string) => Promise; +}; + +const createScreen = (): Screen => { + return { + findByTestId: async (testId: string): Promise => { + const client = getClientInstance(); + return await ( + client.rpc as unknown as { + 'platform.queries.findByTestId': ( + testId: string + ) => Promise; + } + )['platform.queries.findByTestId'](testId); + }, + findAllByTestId: async (testId: string): Promise => { + const client = getClientInstance(); + return await ( + client.rpc as unknown as { + 'platform.queries.findAllByTestId': ( + testId: string + ) => Promise; + } + )['platform.queries.findAllByTestId'](testId); + }, + }; +}; + +export const screen = createScreen(); 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..d65e034 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -193,6 +193,9 @@ importers: packages/bridge: dependencies: + '@react-native-harness/platforms': + specifier: workspace:* + version: link:../platforms '@react-native-harness/tools': specifier: workspace:* version: link:../tools @@ -355,6 +358,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 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 From 74dc96c8fbcd8d96c58000eb57757ddeddf3be62 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 21 Nov 2025 14:17:32 +0100 Subject: [PATCH 2/3] feat: add image snapshots --- apps/playground/jest.config.js | 2 +- .../ui/__image_snapshots__/blue-square.png | Bin 0 -> 19311 bytes .../ui/__image_snapshots__/green-square.png | Bin 0 -> 23581 bytes .../purple-square-will-fail.png | Bin 0 -> 19857 bytes .../ui/__image_snapshots__/red-square.png | Bin 0 -> 19314 bytes .../yellow-square-custom-options.png | Bin 0 -> 21583 bytes .../src/__tests__/ui/actions.harness.tsx | 40 +++++++ .../src/__tests__/ui/queries.harness.tsx | 14 --- .../src/__tests__/ui/screenshot.harness.tsx | 109 ++++++++++++++++++ packages/bridge/package.json | 2 + packages/bridge/src/image-snapshot.ts | 102 ++++++++++++++++ packages/bridge/src/platform-bridge.ts | 3 + packages/bridge/src/server.ts | 17 ++- packages/bridge/src/shared.ts | 42 +++++++ packages/platform-android/src/adb.ts | 11 ++ packages/platform-android/src/runner.ts | 11 ++ packages/platform-android/src/utils.ts | 7 +- packages/platform-ios/src/instance.ts | 14 +++ packages/platform-ios/src/xcrun/simctl.ts | 8 ++ packages/platform-vega/src/runner.ts | 3 + packages/platforms/src/index.ts | 1 + packages/platforms/src/types.ts | 5 + .../expect/matchers/toMatchImageSnapshot.ts | 35 ++++++ packages/runtime/src/runner/factory.ts | 2 + packages/runtime/src/runner/runSuite.ts | 4 + packages/runtime/src/screen/index.ts | 26 ++--- packages/runtime/src/ui/ReadyScreen.tsx | 1 + pnpm-lock.yaml | 66 ++++++----- 28 files changed, 463 insertions(+), 62 deletions(-) create mode 100644 apps/playground/src/__tests__/ui/__image_snapshots__/blue-square.png create mode 100644 apps/playground/src/__tests__/ui/__image_snapshots__/green-square.png create mode 100644 apps/playground/src/__tests__/ui/__image_snapshots__/purple-square-will-fail.png create mode 100644 apps/playground/src/__tests__/ui/__image_snapshots__/red-square.png create mode 100644 apps/playground/src/__tests__/ui/__image_snapshots__/yellow-square-custom-options.png create mode 100644 apps/playground/src/__tests__/ui/actions.harness.tsx create mode 100644 apps/playground/src/__tests__/ui/screenshot.harness.tsx create mode 100644 packages/bridge/src/image-snapshot.ts create mode 100644 packages/runtime/src/expect/matchers/toMatchImageSnapshot.ts 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 0000000000000000000000000000000000000000..0e09b97a76d71bfb9bfc72569cffd0d3b94b8dd9 GIT binary patch literal 19311 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..6d7fe62d34cc1e03e734f538a8d828bca14883c8 GIT binary patch literal 23581 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..390e07b74bd16befebed39e1cafffb4cd2bde164 GIT binary patch literal 19857 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 RIbgrzurDJH)gC-?@gM3Kt+)UH literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..5080948f7040d8d2e30674f14e816915b8d0577f GIT binary patch literal 19314 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 CX7wll literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..947a9148c0d6388f53e8ae9d03b2a2706ed5de5c GIT binary patch literal 21583 zcmeHPX;hQvw*C|qC&W~%GPk6PihzJ1icA%uib4?)ks%625QIoTkTF<}8WDoE%raO} zlvxB4LI?pxs6+^eAwYmgFoeM*gdqtT?-zT|S$CavhjoA5VI@C!`SQ*C-TT?kv-b=4 zes*KJQns+JSuqx`T^N&DHul5YMu_o%rljqih zpTzaQpa9@I;H0C&xtoPwhD_Dkz5{^v50F(MZA6FtPgGVuyS9_(Eyggkh97VCJ*yawW)c6meSQxq-w zXQRLe?gGv27Dq|Xbc}+U8=|lD$9!x=xln@ABJYcv97FO#k26BjnmTqv914C4k_oHL z#VzmR$h%oBtU?3ZlE(2yzAZPI9t)@8hv>0fI@mDz$?cN@sy}Q4TPQ%v(wZ&c@SV*q z6>w1QQd$FagZ~S@F-NBv0&TD6QuE0I{(d0o{qwsSvusb`k_L0L(xd-x%;EoY1QH4O z8-e+E`~?yu(@vK+5(a@2b^2>z9hzZ|>Dn4~J(iHlnh{#qdcUYH9VVS;giRODdL<-; z1DA$(UkCaOb>n_ue5r?WTJbV7m35AZi;p1Bi#6*+vo_d>KEL9)Jq)g_rT{tp2g)=s z9-ahT^4 z&nfxRmD$S1MHAv^)AH&Rx1;iewf33$c6N+Eq0r9ES{Z+Z?5rJgp#wv6s={3CXo-W= z5_KcRY>1b?w|-aFKBm`vm+t*trcVTe$g36>ClY!0DH1%_sj3CRtycFN$d}^YY=Ky_ zxAWRmz?Zb)Pv_rQlj5d-OC?1&s(o!^At)Qg+|1nqs7>0{152oV9{R0|MWM2% zzw7tfV3(WvuPsfZNG7#YdPxmzQ|rejv#w7zn*4n&0JXNT=!XcvK5)g&2(V!+kf64r z7pF6KL3jjiwhEE|r75t3+IqeR7VUogygHEb+4sq6`!_wsI+47$6v;b-*oe((saw*T zR;J97x^y61f|silZW$TSvCS=O=JK2FAfxcOg?DDf&W^ZipB@~aHNjtUz;%3TSA`ZQ zVMe=S{(OL%ctaXH7&F$GIK}A9%)H4eGE3qT8p%b-m*B`uCIr{nrmq0>Pdyhz0daWCCgOa3kY`Un)8Y4x@u@Y-7 zcYiF^KPZ0oBgzM!k#}fcKxaD2AywaU_=f=OSa-5-@(PB2IADgj4d@GzuB$c;b%)ok z0Z3))S8VklO#6`MMHNK165TpZvktOkr$1`fkyiyJhsycr{dUr;9lWmG85==*6B~n} zb050sxAHQ~2y6@(RO2GVHUlT^sM9z~uuZCJt~EhB`6j#QP$$}G@^%9YbR=VZ)iR5N zH(}7|5#mMXbd<0^Q@>S25s`c&PplM9k;PPGh83>^@z{s_{oO2NjbZh6-d(#a+OnnI zLF`nDO82nEnCZ>X*l>ZTmnUx)^e(iGC1bjjHg58~@NBQKrqxS6*9C9)sNmGO(7|z{ zc&w34_17|u@XeQrsHD~-tipvCEVvxA`HOXQh%~5qp(unubGdYuxC>}*+r1w+G&cU#Xkir2P?d?14`L8^y+E4qZx}g-5Y+Qm4$xU&hHq zMjFa5rd%qqpr#K+;1^ut>dVRy8^}18(}CGGw;h5}kKVozv=(RS z#8|FfFxmO3eYd5W<~h7%nd)44{l~7sKa4U)IJ5{u)%l*^0{R}0NQb_RM!6jDX?ht( zt2?CG$W}ys2c$7FFjcL)_1a9YHB>fJGD{4GBMpGZHmg+uwZlpA7K9$lB4iRVnVBV+ z>S7RUVzIa=(yEEOdO0h8u|}#lkRlIURcU#-?_eE0nG)7(i|2p1yG45;VY|e(8P_M zIm6@$kb1n29!#_6pxLw-)*O`Z_0EbQ6Z`>2^IRy5o-G}QWQ_8`XXArl9XXj11Uqbz zon#eU6C&j=gJ} z7F;`WnZmTM9X3gJKVyRR?RakMX>&1jWt1^_zD1tAvWy>=iJifx&vrpvG{&z?K@N{@ zjr{x@g^spsO@G#3%uO<3u}{gYEb8j?r znnz$F^}4uKeb=aCJ*z+ciJYz-M{2o<@&D)fTZZQdK` zaVX)_ts2;q$i*IrN$Z6&FPIJE*G=6HH^W4NOd&6`GIe0MK1{Wtik0OtYlrPU3Q^Vb z6=U^eFJ+H&Dr+ugEN^x1MPJagsT&I}858Agg#_urt~*%R1v(9v*!*FDk-j3$s!MR9 zS%|9cg&w!!md|E{l_DSo{f!k6(nORzE8z56haWmca=BD?rqWOTmi@}5W09QORhk(i zy~HU9tC*QYhsh(hS5|$)tA=|n=K6k+9c_rcM|RSp^kQSZ`$cnJ1GTLq{8!6M9WmHg z!9$&xy_0^25~lyaexmm?N`r`>aSnv^<7vQm$(A<&KtCWmwElAl{0jCYx<48 z1+zyv>(ur;JYK2u-6p$CiV#ryQjQ>p4lWg}ErZP`w}X4N8wej*v|Gz~qs5+r(VXB7KzmtCvqMk2^u23+Sx zf)B`C5}dg|nMGY;SKpi)dtF~UdygG4SEFY{SU6>26W}<>75yGpBIx+X>sXF8@GY~j z<0Ck#Tj7N>wJnGv^ViCm-<2)0u=3>aqNBtR_akG(6?bymEy!f&3EybW@Xsf|$XV@?wvo!Vh_jFFz z@*3-zc%x2Y?`HLc$#3lR+s<1WphzIj2XOBdaS1|7gts^O%W$~f^=d*)M@8lNMZp-A zFCs`|*ok4W`>w{_Iq67?odWq`sEC7yMzrGCWTIevBz3G59h$JxT48`m`0x}{LY#j+ zLbBx#l`b=n*4tS>spT#@C4wmn0!sb(jtAdE?8M#{NLCR9CcY?jSkLM)432L&wm8`^ z(9JZyPm3NvU4B!sV&3~g=fEp(508~bypf8k6PPnCkfIrgy6uqY6_=T*tc~P zcOa_TTkDuPkjm;EropZn(8)Vmp|xZ5u-XxoJ`k4Dge)Z5ikW4GoXlXJ&k=L9>ZS^q zJDGH8&FJtSP*nv3$Jqj)>< zYfP+Xtb$TNL~0iU&#Bfdtx4$>U%E0!O&g8&=)ksqYZ~a(HO{i8@5{WUGG}5A669oY zcIrz^y0-Tgr$>Q*fe+>{?D!Y2!IH1dQ+5?T|Xk`9>8uW~%H_x1AXE zjj9Zc2r90aGs>x~NwfO=z)X7EB6n{0?py;h2@$v&qC4aqQRIn|xr&*`D1gxx|$-M;4!1SaU9HL2yB3c`L#a zWPabCM_I875raR6iKFH}A-ubvo@94rpnADv^z@v#1+PHDvBLIM_HRB~rLSKa=H(3(Be|tha}FgYni?3aH5~F#)YJ1MA$Gh7uGDekGeUTfjUf2&$Fm( zVLkYsIfyMvBIBZ<#RD|~;+GdBq6&I(PQtA>gUq_uso8jQeOY?NEC zv(F3(O%pP9R<6X-GcLq2$9+m_F63a=f_i@98}RA=xjJhOR@l|dlM_3vxmhy_-0CEb zWD$BEMmSxI_3s!@pdw2Y$q>{$#R>YwNL$dv`nCcb>Wt;Rp}2 zs_LcR9|DB)U?D+2X3QdB=w)a4CeRH1@IKAb9n1Vvz{_hRF$1hgnV1IS z{9RWm>Jy+K0&|OOPTlIU8-Sx|H$9RtHD3FMg{o-dH))s*> zbhHU%N&6S?>S~&XU+nl;&cq#wIt5&9E&PlnW`M{zNDbtSqM&G$aQ=-Majs@}v}DGO zx%s!UG6i+u(`w^;hJ&g8X&pZFI<&PV_0dY;ruBgmOSBEe^JS;0SBY-p(RE2dbQ;j8fUMIv0=ErcZhe@8y z(N+WYWRQ$37b ze7U8nzeA&ZBkQJ$VxM3O+cwaSsIIVPS>N$#IP9e4)x@hpT#wV81^n* z1zQrgfNV+9ZD@i!9Sq{hU$=tp(Ayim@L5EPASQL(Qa!)utPP2zctZ4$EuUX)+L6UG zM<08&SvUT@X(L%(dspP^4HU`DbLV5iOLHY+nHUmWRtq^73Q`WS`D9Yr6)}RR|X|jG7Q-kf@h!SG?^ijac90zyqsHv z0gdko0;hBx{O6b(F+M5X|0w?}DyIjHRKUX9$IqWqbA2Of3HlL)8}EqZLXF6w%aGXd zVOelB*8d1;5-uxSX2Ih3FQYJCxD!(>GMNGYH><1i1K-W~ex#?^}?KfgfdmS=mwt-9Wj(K)KvP z*^gJ^;XjMm|L47bj)v{LD>%MVA-8JdvfpOT#x)FQoPQf>+$aOYuYWIl=i(vPZ%+ny>mx@ zr7vwhe9Zo@lHTXP7`k!;P#OfR)++r#X#+3^Dg8icgRku$B_1eaL5T-Sjsz%qBA~PZ zhy+SMP})Gzuu%Ge(gy#1KN#;GRRKaSeqp1%Om+qaCxlhi*cK;j*%})aWo=wr)Aeo` zae!aGLi(%=dlLNS5Kwbxqb;pQ#3F`n;bv>In zSuzI#G$gKw!X?6$u&P=+9h2hf%pp2iM8PP=U%RcM7P4Ug literal 0 HcmV?d00001 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 index 01c51ef..4b6c755 100644 --- a/apps/playground/src/__tests__/ui/queries.harness.tsx +++ b/apps/playground/src/__tests__/ui/queries.harness.tsx @@ -38,18 +38,4 @@ describe('Queries', () => { expect(Array.isArray(elements)).toBe(true); expect(elements.length).toBe(2); }); - - test('should tap element found by testID', async () => { - await render( - - - This is a view with a testID - - - ); - const element = await screen.findByTestId('this-is-test-id'); - await userEvent.tap(element); - // If tap succeeds without throwing, the test passes - expect(element).toBeDefined(); - }); }); 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 c385301..e2326a7 100644 --- a/packages/bridge/package.json +++ b/packages/bridge/package.json @@ -30,10 +30,12 @@ "@react-native-harness/platforms": "workspace:*", "@react-native-harness/tools": "workspace:*", "birpc": "^2.4.0", + "pngjs": "^7.0.0", "tslib": "^2.3.0", "ws": "^8.18.2" }, "devDependencies": { + "@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..3cb34d3 --- /dev/null +++ b/packages/bridge/src/image-snapshot.ts @@ -0,0 +1,102 @@ +import pixelmatch, { type PixelmatchOptions } from 'pixelmatch'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { PNG } from 'pngjs'; +import type { FileReference, ImageSnapshotOptions } from './shared.js'; + +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, + testPath: string, + options: ImageSnapshotOptions +) => { + 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(testPath); + const snapshotsDir = path.join(testDir, SNAPSHOT_DIR_NAME); + + 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 }); + + // 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 index 43cd73a..b83af28 100644 --- a/packages/bridge/src/platform-bridge.ts +++ b/packages/bridge/src/platform-bridge.ts @@ -17,6 +17,9 @@ export const createPlatformBridgeFunctions = ( '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(); }, diff --git a/packages/bridge/src/server.ts b/packages/bridge/src/server.ts index 6416bec..0a11188 100644 --- a/packages/bridge/src/server.ts +++ b/packages/bridge/src/server.ts @@ -7,11 +7,16 @@ import type { BridgeClientFunctions, DeviceDescriptor, BridgeEvents, + ImageSnapshotOptions, } from './shared.js'; import { deserialize, serialize } from './serializer.js'; import { DeviceNotRespondingError } from './errors.js'; import { createPlatformBridgeFunctions } from './platform-bridge.js'; -import type { HarnessPlatformRunner } from '@react-native-harness/platforms'; +import type { + FileReference, + HarnessPlatformRunner, +} from '@react-native-harness/platforms'; +import { matchImageSnapshot } from './image-snapshot.js'; export type BridgeServerOptions = { port: number; @@ -79,6 +84,16 @@ export const getBridgeServer = async ({ '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); + }, }; const group = createBirpcGroup( diff --git a/packages/bridge/src/shared.ts b/packages/bridge/src/shared.ts index 9b58bd2..85da245 100644 --- a/packages/bridge/src/shared.ts +++ b/packages/bridge/src/shared.ts @@ -7,13 +7,49 @@ import type { BundlerEvents } from './shared/bundler.js'; import type { UIElement, ElementReference, + FileReference, } 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, TestCollectionStartedEvent, @@ -87,6 +123,7 @@ export type BridgeServerFunctions = { '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 @@ -94,4 +131,9 @@ export type BridgeServerFunctions = { 'platform.queries.findAllByTestId': ( testId: string ) => Promise; + 'test.matchImageSnapshot': ( + screenshot: FileReference, + testPath: string, + options: ImageSnapshotOptions + ) => Promise<{ pass: boolean; message: string }>; }; diff --git a/packages/platform-android/src/adb.ts b/packages/platform-android/src/adb.ts index e2466ef..f397fab 100644 --- a/packages/platform-android/src/adb.ts +++ b/packages/platform-android/src/adb.ts @@ -133,3 +133,14 @@ export const inputText = async (adbId: string, text: string): Promise => { 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 6aa21ba..b5b4e8a 100644 --- a/packages/platform-android/src/runner.ts +++ b/packages/platform-android/src/runner.ts @@ -17,6 +17,9 @@ import { 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 @@ -106,6 +109,14 @@ const getAndroidRunner = async ( // 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 c7fd835..2903a91 100644 --- a/packages/platform-android/src/utils.ts +++ b/packages/platform-android/src/utils.ts @@ -138,10 +138,9 @@ const findElementsByTestId = ( // 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['testID'] || - element.attributes['test-id'] || - element.attributes['content-desc']; + const elementTestId = element.attributes['resource-id']; + + console.log('elementTestId: ' + elementTestId + ', testId: ' + testId); if (elementTestId === testId) { results.push({ element, path }); diff --git a/packages/platform-ios/src/instance.ts b/packages/platform-ios/src/instance.ts index b485f5c..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 @@ -85,6 +88,14 @@ export const getAppleSimulatorPlatformInstance = async ( 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 }; + }, }, }; }; @@ -144,6 +155,9 @@ export const getApplePhysicalDevicePlatformInstance = async ( 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 a8d77c3..6c6feaf 100644 --- a/packages/platform-vega/src/runner.ts +++ b/packages/platform-vega/src/runner.ts @@ -59,6 +59,9 @@ const getVegaRunner = async ( 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 1444e33..ad25b13 100644 --- a/packages/platforms/src/index.ts +++ b/packages/platforms/src/index.ts @@ -5,5 +5,6 @@ export type { 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 e983425..cc70f6e 100644 --- a/packages/platforms/src/types.ts +++ b/packages/platforms/src/types.ts @@ -11,6 +11,7 @@ export type PlatformActions = { tap: (x: number, y: number) => Promise; inputText: (text: string) => Promise; tapElement: (element: ElementReference) => Promise; + screenshot: () => Promise; }; export type PlatformQueries = { @@ -37,3 +38,7 @@ export type HarnessPlatform> = { export type ElementReference = { id: string; }; + +export type FileReference = { + path: string; +}; diff --git a/packages/runtime/src/expect/matchers/toMatchImageSnapshot.ts b/packages/runtime/src/expect/matchers/toMatchImageSnapshot.ts new file mode 100644 index 0000000..c237956 --- /dev/null +++ b/packages/runtime/src/expect/matchers/toMatchImageSnapshot.ts @@ -0,0 +1,35 @@ +import { getClientInstance } from '../../client/store.js'; +import type { MatcherState } from '@vitest/expect'; +import type { + FileReference, + ImageSnapshotOptions, +} from '@react-native-harness/bridge'; +import { expect } from '../index.js'; + +declare module '@vitest/expect' { + interface Matchers { + toMatchImageSnapshot(options: ImageSnapshotOptions): Promise; + } +} + +expect.extend({ + toMatchImageSnapshot, +}); + +async function toMatchImageSnapshot( + this: MatcherState, + received: FileReference, + options: ImageSnapshotOptions +): Promise<{ pass: boolean; message: () => string }> { + const client = getClientInstance(); + const result = await client.rpc['test.matchImageSnapshot']( + received, + globalThis['HARNESS_TEST_PATH'], + options + ); + + return { + pass: result.pass, + message: () => result.message, + }; +} diff --git a/packages/runtime/src/runner/factory.ts b/packages/runtime/src/runner/factory.ts index c78d60c..babf2ca 100644 --- a/packages/runtime/src/runner/factory.ts +++ b/packages/runtime/src/runner/factory.ts @@ -9,6 +9,8 @@ export const getTestRunner = (): TestRunner => { return { events, run: async (testSuite, testFilePath) => { + globalThis['HARNESS_TEST_PATH'] = testFilePath; + const result = await runSuite(testSuite, { events, testFilePath, 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/screen/index.ts b/packages/runtime/src/screen/index.ts index 6f979e1..ff35a15 100644 --- a/packages/runtime/src/screen/index.ts +++ b/packages/runtime/src/screen/index.ts @@ -1,32 +1,28 @@ -import type { ElementReference } from '@react-native-harness/bridge'; +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 as unknown as { - 'platform.queries.findByTestId': ( - testId: string - ) => Promise; - } - )['platform.queries.findByTestId'](testId); + return await client.rpc['platform.queries.findByTestId'](testId); }, findAllByTestId: async (testId: string): Promise => { const client = getClientInstance(); - return await ( - client.rpc as unknown as { - 'platform.queries.findAllByTestId': ( - testId: string - ) => Promise; - } - )['platform.queries.findAllByTestId'](testId); + return await client.rpc['platform.queries.findAllByTestId'](testId); + }, + screenshot: async (): Promise => { + const client = getClientInstance(); + return await client.rpc['platform.actions.screenshot'](); }, }; }; 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 ( +