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 ( +