diff --git a/.nx/version-plans/version-plan-1768506559411.md b/.nx/version-plans/version-plan-1768506559411.md new file mode 100644 index 0000000..e553a99 --- /dev/null +++ b/.nx/version-plans/version-plan-1768506559411.md @@ -0,0 +1,5 @@ +--- +__default__: prerelease +--- + +Introduces UI testing capabilities with a new `@react-native-harness/ui` package that provides screen queries, user event simulation (press, type), and visual regression testing through `toMatchImageSnapshot`. This enables comprehensive component and integration testing with real device interactions, similar to React Testing Library but running on actual iOS and Android devices. diff --git a/actions/android/action.yml b/actions/android/action.yml index 820df9e..5c4e5a4 100644 --- a/actions/android/action.yml +++ b/actions/android/action.yml @@ -12,6 +12,11 @@ inputs: description: The project root directory required: false type: string + uploadVisualTestArtifacts: + description: Whether to upload visual test diff and actual images as artifacts + required: false + type: boolean + default: 'true' runs: using: 'composite' steps: @@ -112,3 +117,12 @@ runs: echo $(pwd) adb install -r ${{ inputs.app }} pnpm react-native-harness --harnessRunner ${{ inputs.runner }} + - name: Upload visual test artifacts + if: always() && inputs.uploadVisualTestArtifacts == 'true' + uses: actions/upload-artifact@v4 + with: + name: visual-test-diffs-android + path: | + ${{ inputs.projectRoot }}/**/__image_snapshots__/**/*-diff.png + ${{ inputs.projectRoot }}/**/__image_snapshots__/**/*-actual.png + if-no-files-found: ignore diff --git a/actions/ios/action.yml b/actions/ios/action.yml index 173c923..706d573 100644 --- a/actions/ios/action.yml +++ b/actions/ios/action.yml @@ -12,6 +12,11 @@ inputs: description: The project root directory required: false type: string + uploadVisualTestArtifacts: + description: Whether to upload visual test diff and actual images as artifacts + required: false + type: boolean + default: 'true' runs: using: 'composite' steps: @@ -40,3 +45,12 @@ runs: working-directory: ${{ inputs.projectRoot }} run: | pnpm react-native-harness --harnessRunner ${{ inputs.runner }} + - name: Upload visual test artifacts + if: always() && inputs.uploadVisualTestArtifacts == 'true' + uses: actions/upload-artifact@v4 + with: + name: visual-test-diffs-ios + path: | + ${{ inputs.projectRoot }}/**/__image_snapshots__/**/*-diff.png + ${{ inputs.projectRoot }}/**/__image_snapshots__/**/*-actual.png + if-no-files-found: ignore diff --git a/actions/shared/index.cjs b/actions/shared/index.cjs index c1de695..41e40ce 100644 --- a/actions/shared/index.cjs +++ b/actions/shared/index.cjs @@ -29,9 +29,9 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge mod )); -// ../../node_modules/.pnpm/picocolors@1.1.1/node_modules/picocolors/picocolors.js +// ../../node_modules/picocolors/picocolors.js var require_picocolors = __commonJS({ - "../../node_modules/.pnpm/picocolors@1.1.1/node_modules/picocolors/picocolors.js"(exports2, module2) { + "../../node_modules/picocolors/picocolors.js"(exports2, module2) { "use strict"; var p = process || {}; var argv = p.argv || []; @@ -102,9 +102,9 @@ var require_picocolors = __commonJS({ } }); -// ../../node_modules/.pnpm/sisteransi@1.0.5/node_modules/sisteransi/src/index.js +// ../../node_modules/sisteransi/src/index.js var require_src = __commonJS({ - "../../node_modules/.pnpm/sisteransi@1.0.5/node_modules/sisteransi/src/index.js"(exports2, module2) { + "../../node_modules/sisteransi/src/index.js"(exports2, module2) { "use strict"; var ESC = "\x1B"; var CSI = `${ESC}[`; @@ -158,9 +158,9 @@ var require_src = __commonJS({ } }); -// ../../node_modules/.pnpm/is-unicode-supported@0.1.0/node_modules/is-unicode-supported/index.js +// ../../node_modules/is-unicode-supported/index.js var require_is_unicode_supported = __commonJS({ - "../../node_modules/.pnpm/is-unicode-supported@0.1.0/node_modules/is-unicode-supported/index.js"(exports2, module2) { + "../../node_modules/is-unicode-supported/index.js"(exports2, module2) { "use strict"; module2.exports = () => { if (process.platform !== "win32") { @@ -172,7 +172,7 @@ var require_is_unicode_supported = __commonJS({ } }); -// ../../node_modules/.pnpm/zod@3.25.67/node_modules/zod/dist/esm/v3/external.js +// ../../node_modules/zod/dist/esm/v3/external.js var external_exports = {}; __export(external_exports, { BRAND: () => BRAND, @@ -284,7 +284,7 @@ __export(external_exports, { void: () => voidType }); -// ../../node_modules/.pnpm/zod@3.25.67/node_modules/zod/dist/esm/v3/helpers/util.js +// ../../node_modules/zod/dist/esm/v3/helpers/util.js var util; (function(util3) { util3.assertEqual = (_) => { @@ -418,7 +418,7 @@ var getParsedType = (data) => { } }; -// ../../node_modules/.pnpm/zod@3.25.67/node_modules/zod/dist/esm/v3/ZodError.js +// ../../node_modules/zod/dist/esm/v3/ZodError.js var ZodIssueCode = util.arrayToEnum([ "invalid_type", "invalid_literal", @@ -535,7 +535,7 @@ ZodError.create = (issues) => { return error; }; -// ../../node_modules/.pnpm/zod@3.25.67/node_modules/zod/dist/esm/v3/locales/en.js +// ../../node_modules/zod/dist/esm/v3/locales/en.js var errorMap = (issue, _ctx) => { let message; switch (issue.code) { @@ -636,7 +636,7 @@ var errorMap = (issue, _ctx) => { }; var en_default = errorMap; -// ../../node_modules/.pnpm/zod@3.25.67/node_modules/zod/dist/esm/v3/errors.js +// ../../node_modules/zod/dist/esm/v3/errors.js var overrideErrorMap = en_default; function setErrorMap(map) { overrideErrorMap = map; @@ -645,7 +645,7 @@ function getErrorMap() { return overrideErrorMap; } -// ../../node_modules/.pnpm/zod@3.25.67/node_modules/zod/dist/esm/v3/helpers/parseUtil.js +// ../../node_modules/zod/dist/esm/v3/helpers/parseUtil.js var makeIssue = (params) => { const { data, path: path4, errorMaps, issueData } = params; const fullPath = [...path4, ...issueData.path || []]; @@ -755,14 +755,14 @@ var isDirty = (x) => x.status === "dirty"; var isValid = (x) => x.status === "valid"; var isAsync = (x) => typeof Promise !== "undefined" && x instanceof Promise; -// ../../node_modules/.pnpm/zod@3.25.67/node_modules/zod/dist/esm/v3/helpers/errorUtil.js +// ../../node_modules/zod/dist/esm/v3/helpers/errorUtil.js var errorUtil; (function(errorUtil2) { errorUtil2.errToObj = (message) => typeof message === "string" ? { message } : message || {}; errorUtil2.toString = (message) => typeof message === "string" ? message : message?.message; })(errorUtil || (errorUtil = {})); -// ../../node_modules/.pnpm/zod@3.25.67/node_modules/zod/dist/esm/v3/types.js +// ../../node_modules/zod/dist/esm/v3/types.js var ParseInputLazyPath = class { constructor(parent, value, path4, key) { this._cachedPath = []; @@ -4214,6 +4214,7 @@ var ConfigSchema = external_exports.object({ appRegistryComponentName: external_exports.string().min(1, "App registry component name is required"), runners: external_exports.array(external_exports.any()).min(1, "At least one runner is required"), defaultRunner: external_exports.string().optional(), + webSocketPort: external_exports.number().optional().default(3001), bridgeTimeout: external_exports.number().min(1e3, "Bridge timeout must be at least 1 second").default(6e4), resetEnvironmentBetweenTestFiles: external_exports.boolean().optional().default(true), unstable__skipAlreadyIncludedModules: external_exports.boolean().optional().default(false), @@ -4233,7 +4234,7 @@ var ConfigSchema = external_exports.object({ // ../tools/dist/logger.js var import_node_util2 = __toESM(require("util"), 1); -// ../../node_modules/.pnpm/@clack+core@1.0.0-alpha.5/node_modules/@clack/core/dist/index.mjs +// ../../node_modules/@clack/core/dist/index.mjs var import_node_process = require("process"); var V = __toESM(require("readline"), 1); var import_node_readline = __toESM(require("readline"), 1); @@ -4251,7 +4252,7 @@ var C = { actions: new Set(gt), aliases: /* @__PURE__ */ new Map([["k", "up"], [ var At = globalThis.process.platform.startsWith("win"); var G = Symbol("clack:cancel"); -// ../../node_modules/.pnpm/@clack+prompts@1.0.0-alpha.5/node_modules/@clack/prompts/dist/index.mjs +// ../../node_modules/@clack/prompts/dist/index.mjs var import_picocolors = __toESM(require_picocolors(), 1); var import_node_process2 = __toESM(require("process"), 1); var import_node_fs = require("fs"); diff --git a/apps/playground/ios/Podfile.lock b/apps/playground/ios/Podfile.lock index bdbb4b4..22b1c53 100644 --- a/apps/playground/ios/Podfile.lock +++ b/apps/playground/ios/Podfile.lock @@ -5,6 +5,34 @@ PODS: - FBLazyVector (0.82.1) - fmt (11.0.2) - glog (0.3.5) + - HarnessUI (1.0.0-alpha.20): + - boost + - DoubleConversion + - fast_float + - fmt + - glog + - hermes-engine + - RCT-Folly + - RCT-Folly/Fabric + - RCTRequired + - RCTTypeSafety + - React-Core + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - SocketRocket + - Yoga - hermes-engine (0.82.1): - hermes-engine/Pre-built (= 0.82.1) - hermes-engine/Pre-built (0.82.1) @@ -2331,6 +2359,7 @@ DEPENDENCIES: - FBLazyVector (from `../../../node_modules/react-native/Libraries/FBLazyVector`) - fmt (from `../../../node_modules/react-native/third-party-podspecs/fmt.podspec`) - glog (from `../../../node_modules/react-native/third-party-podspecs/glog.podspec`) + - "HarnessUI (from `../node_modules/@react-native-harness/ui`)" - hermes-engine (from `../../../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec`) - RCT-Folly (from `../../../node_modules/react-native/third-party-podspecs/RCT-Folly.podspec`) - RCTDeprecation (from `../../../node_modules/react-native/ReactApple/Libraries/RCTFoundation/RCTDeprecation`) @@ -2418,6 +2447,8 @@ EXTERNAL SOURCES: :podspec: "../../../node_modules/react-native/third-party-podspecs/fmt.podspec" glog: :podspec: "../../../node_modules/react-native/third-party-podspecs/glog.podspec" + HarnessUI: + :path: "../node_modules/@react-native-harness/ui" hermes-engine: :podspec: "../../../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec" :tag: hermes-2025-09-01-RNv0.82.0-265ef62ff3eb7289d17e366664ac0da82303e101 @@ -2561,6 +2592,7 @@ SPEC CHECKSUMS: FBLazyVector: 0aa6183b9afe3c31fc65b5d1eeef1f3c19b63bfa fmt: a40bb5bd0294ea969aaaba240a927bd33d878cdd glog: 5683914934d5b6e4240e497e0f4a3b42d1854183 + HarnessUI: 2957b94c9c4a7e6e54b636229f4aa5e3809936bf hermes-engine: 273e30e7fb618279934b0b95ffab60ecedb7acf5 RCT-Folly: 846fda9475e61ec7bcbf8a3fe81edfcaeb090669 RCTDeprecation: f17e2ebc07876ca9ab8eb6e4b0a4e4647497ae3a diff --git a/apps/playground/jest.config.js b/apps/playground/jest.config.js index 982a675..d785bdd 100644 --- a/apps/playground/jest.config.js +++ b/apps/playground/jest.config.js @@ -10,7 +10,7 @@ module.exports = { setupFilesAfterEnv: ['./src/setupFileAfterEnv.ts'], // This is necessary to prevent Jest from transforming the workspace packages. // Not needed in users projects, as they will have the packages installed in their node_modules. - transformIgnorePatterns: ['/packages/'], + transformIgnorePatterns: ['/packages/', '/node_modules/'], }, ], collectCoverageFrom: ['./src/**/*.(ts|tsx)'], diff --git a/apps/playground/package.json b/apps/playground/package.json index 9bef03f..6b780fa 100644 --- a/apps/playground/package.json +++ b/apps/playground/package.json @@ -11,6 +11,8 @@ }, "devDependencies": { "react-native-harness": "workspace:*", + "@react-native-harness/runtime": "workspace:*", + "@react-native-harness/ui": "workspace:*", "@react-native-community/cli": "20.0.0", "@react-native-community/cli-platform-android": "20.0.0", "@react-native-community/cli-platform-ios": "20.0.0", diff --git a/apps/playground/src/__tests__/ui/__image_snapshots__/android/orange-square-element-only.png b/apps/playground/src/__tests__/ui/__image_snapshots__/android/orange-square-element-only.png new file mode 100644 index 0000000..0fe0b77 Binary files /dev/null and b/apps/playground/src/__tests__/ui/__image_snapshots__/android/orange-square-element-only.png differ diff --git a/apps/playground/src/__tests__/ui/__image_snapshots__/ios/orange-square-element-only.png b/apps/playground/src/__tests__/ui/__image_snapshots__/ios/orange-square-element-only.png new file mode 100644 index 0000000..6d7bf08 Binary files /dev/null and b/apps/playground/src/__tests__/ui/__image_snapshots__/ios/orange-square-element-only.png differ diff --git a/apps/playground/src/__tests__/ui/actions.harness.tsx b/apps/playground/src/__tests__/ui/actions.harness.tsx new file mode 100644 index 0000000..c7685d7 --- /dev/null +++ b/apps/playground/src/__tests__/ui/actions.harness.tsx @@ -0,0 +1,33 @@ +import { describe, test, render, fn, expect } from 'react-native-harness'; +import { screen, userEvent } from '@react-native-harness/ui'; +import { View, Text, Pressable } from 'react-native'; + +describe('Actions', () => { + test('should press element found by testID', async () => { + const onPress = fn(); + + await render( + + + This is a view with a testID + + + ); + + const element = await screen.findByTestId('this-is-test-id'); + await userEvent.press(element); + + expect(onPress).toHaveBeenCalled(); + }); +}); diff --git a/apps/playground/src/__tests__/ui/queries.harness.tsx b/apps/playground/src/__tests__/ui/queries.harness.tsx new file mode 100644 index 0000000..f346128 --- /dev/null +++ b/apps/playground/src/__tests__/ui/queries.harness.tsx @@ -0,0 +1,34 @@ +import { View, Text } from 'react-native'; +import { describe, test, expect, render } from 'react-native-harness'; +import { screen } from '@react-native-harness/ui'; + +describe('Queries', () => { + test('should find element by testID', async () => { + await render( + + + This is a view with a testID + + + ); + const element = await screen.findByTestId('this-is-test-id'); + expect(element).toBeDefined(); + }); + + test('should find all elements by testID', async () => { + await render( + + + First element + + + Second element + + + ); + const elements = await screen.findAllByTestId('this-is-test-id'); + expect(elements).toBeDefined(); + expect(Array.isArray(elements)).toBe(true); + expect(elements.length).toBe(2); + }); +}); diff --git a/apps/playground/src/__tests__/ui/screenshot.harness.tsx b/apps/playground/src/__tests__/ui/screenshot.harness.tsx new file mode 100644 index 0000000..78e44eb --- /dev/null +++ b/apps/playground/src/__tests__/ui/screenshot.harness.tsx @@ -0,0 +1,40 @@ +import { describe, test, render, expect } from 'react-native-harness'; +import { View, Text } from 'react-native'; +import { screen } from '@react-native-harness/ui'; + +describe('Screenshot', () => { + test('should screenshot specific element only', async () => { + await render( + + + + Target + + + + ); + + const targetElement = await screen.findByTestId('target-element'); + const screenshot = await screen.screenshot(targetElement); + await expect(screenshot).toMatchImageSnapshot({ + name: 'orange-square-element-only', + }); + }); +}); diff --git a/apps/playground/src/__tests__/ui/type.harness.tsx b/apps/playground/src/__tests__/ui/type.harness.tsx new file mode 100644 index 0000000..5ededbe --- /dev/null +++ b/apps/playground/src/__tests__/ui/type.harness.tsx @@ -0,0 +1,124 @@ +import { describe, test, render, fn, expect } from 'react-native-harness'; +import { screen, userEvent } from '@react-native-harness/ui'; +import { View, TextInput } from 'react-native'; + +describe('userEvent.type', () => { + test('should type text into TextInput and trigger onChangeText', async () => { + const onChangeText = fn(); + + await render( + + + + ); + + const textInput = await screen.findByTestId('text-input'); + await userEvent.type(textInput, 'Hello'); + + // onChangeText should be called for each character + expect(onChangeText).toHaveBeenCalledTimes(5); + + // Verify the progressive text changes + expect(onChangeText).toHaveBeenNthCalledWith(1, 'H'); + expect(onChangeText).toHaveBeenNthCalledWith(2, 'He'); + expect(onChangeText).toHaveBeenNthCalledWith(3, 'Hel'); + expect(onChangeText).toHaveBeenNthCalledWith(4, 'Hell'); + expect(onChangeText).toHaveBeenNthCalledWith(5, 'Hello'); + }); + + test('should append to existing text', async () => { + const onChangeText = fn(); + + await render( + + + + ); + + const textInput = await screen.findByTestId('text-input'); + await userEvent.type(textInput, 'there'); + + // Should append to existing "Hi " text + expect(onChangeText).toHaveBeenLastCalledWith('Hi there'); + }); + + test('should trigger onBlur when typing completes', async () => { + const onBlur = fn(); + + await render( + + + + ); + + const textInput = await screen.findByTestId('text-input'); + await userEvent.type(textInput, 'test'); + + expect(onBlur).toHaveBeenCalled(); + }); + + test('should not trigger blur when skipBlur is true', async () => { + const onBlur = fn(); + + await render( + + + + ); + + const textInput = await screen.findByTestId('text-input'); + await userEvent.type(textInput, 'test', { skipBlur: true }); + + expect(onBlur).not.toHaveBeenCalled(); + }); + + test('should trigger onSubmitEditing when submitEditing option is true', async () => { + const onSubmitEditing = fn(); + + await render( + + + + ); + + const textInput = await screen.findByTestId('text-input'); + await userEvent.type(textInput, 'test', { submitEditing: true }); + + expect(onSubmitEditing).toHaveBeenCalled(); + }); +}); diff --git a/apps/playground/tsconfig.app.json b/apps/playground/tsconfig.app.json index 3b6ab83..a90580b 100644 --- a/apps/playground/tsconfig.app.json +++ b/apps/playground/tsconfig.app.json @@ -25,13 +25,7 @@ "src/**/*.spec.jsx", "src/test-setup.ts" ], - "include": [ - "src/**/*.ts", - "src/**/*.tsx", - "src/**/*.js", - "src/**/*.jsx", - "react-native-harness.d.ts" - ], + "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.js", "src/**/*.jsx"], "references": [ { "path": "../../packages/platform-vega/tsconfig.lib.json" @@ -45,6 +39,12 @@ { "path": "../../packages/jest/tsconfig.lib.json" }, + { + "path": "../../packages/ui/tsconfig.lib.json" + }, + { + "path": "../../packages/runtime/tsconfig.lib.json" + }, { "path": "../../packages/react-native-harness/tsconfig.lib.json" } diff --git a/apps/playground/tsconfig.json b/apps/playground/tsconfig.json index b495afc..fc60f68 100644 --- a/apps/playground/tsconfig.json +++ b/apps/playground/tsconfig.json @@ -15,6 +15,12 @@ { "path": "../../packages/jest" }, + { + "path": "../../packages/ui" + }, + { + "path": "../../packages/runtime" + }, { "path": "../../packages/react-native-harness" }, diff --git a/packages/bridge/package.json b/packages/bridge/package.json index 9a95328..25f6a9b 100644 --- a/packages/bridge/package.json +++ b/packages/bridge/package.json @@ -27,12 +27,18 @@ } }, "dependencies": { + "@react-native-harness/platforms": "workspace:*", "@react-native-harness/tools": "workspace:*", "birpc": "^2.4.0", + "pixelmatch": "^7.1.0", + "pngjs": "^7.0.0", + "ssim.js": "^3.5.0", "tslib": "^2.3.0", "ws": "^8.18.2" }, "devDependencies": { + "@types/pixelmatch": "^5.2.6", + "@types/pngjs": "^6.0.5", "@types/ws": "^8.18.1" }, "license": "MIT" diff --git a/packages/bridge/src/binary-transfer.ts b/packages/bridge/src/binary-transfer.ts new file mode 100644 index 0000000..c2d6f85 --- /dev/null +++ b/packages/bridge/src/binary-transfer.ts @@ -0,0 +1,79 @@ +export const HEADER_SIZE = 8; + +export function createBinaryFrame( + transferId: number, + data: Uint8Array +): Uint8Array { + const frame = new Uint8Array(HEADER_SIZE + data.length); + const view = new DataView(frame.buffer); + + // Transfer ID (4 bytes, Big Endian) + view.setUint32(0, transferId, false); + + // Reserved (4 bytes) - set to 0 + view.setUint32(4, 0, false); + + // Copy data + frame.set(data, HEADER_SIZE); + + return frame; +} + +export function parseBinaryFrame(frame: Uint8Array): { + transferId: number; + data: Uint8Array; +} { + const view = new DataView(frame.buffer, frame.byteOffset, frame.byteLength); + const transferId = view.getUint32(0, false); + const data = frame.subarray(HEADER_SIZE); + + return { transferId, data }; +} + +export class BinaryStore { + private store = new Map(); + private timeouts = new Map(); + // 5 minutes timeout for binary data + private readonly TIMEOUT_MS = 5 * 60 * 1000; + + add(transferId: number, data: Uint8Array): void { + this.store.set(transferId, data); + const timeout = setTimeout(() => { + this.store.delete(transferId); + this.timeouts.delete(transferId); + }, this.TIMEOUT_MS); + this.timeouts.set(transferId, timeout); + } + + get(transferId: number): Uint8Array | undefined { + return this.store.get(transferId); + } + + delete(transferId: number): boolean { + const timeout = this.timeouts.get(transferId); + if (timeout) { + clearTimeout(timeout); + this.timeouts.delete(transferId); + } + return this.store.delete(transferId); + } + + dispose(): void { + for (const timeout of this.timeouts.values()) { + clearTimeout(timeout); + } + this.timeouts.clear(); + this.store.clear(); + } +} + +let nextTransferId = 1; +export function generateTransferId(): number { + // Use a rolling counter, but ensure it doesn't overflow 32-bit integer just in case + // though JS numbers are doubles, we are writing to Uint32. + const id = nextTransferId++; + if (nextTransferId > 0xffffffff) { + nextTransferId = 1; + } + return id; +} diff --git a/packages/bridge/src/client.ts b/packages/bridge/src/client.ts index 6e80ff0..ceb3af3 100644 --- a/packages/bridge/src/client.ts +++ b/packages/bridge/src/client.ts @@ -1,10 +1,12 @@ import { BirpcReturn, createBirpc } from 'birpc'; import type { BridgeClientFunctions, BridgeServerFunctions } from './shared.js'; import { deserialize, serialize } from './serializer.js'; +import { createBinaryFrame } from './binary-transfer.js'; export type BridgeClient = { rpc: BirpcReturn; disconnect: () => void; + sendBinary: (transferId: number, data: Uint8Array) => void; }; const getBridgeClient = async ( @@ -13,6 +15,7 @@ const getBridgeClient = async ( ): Promise => { return new Promise((resolve) => { const ws = new WebSocket(url); + ws.binaryType = 'arraybuffer'; const handleOpen = () => { const rpc = createBirpc( @@ -21,7 +24,9 @@ const getBridgeClient = async ( post: (data) => ws.send(data), on: (handler) => { ws.addEventListener('message', (event: any) => { - handler(event.data); + if (typeof event.data === 'string') { + handler(event.data); + } }); }, serialize, @@ -34,6 +39,10 @@ const getBridgeClient = async ( disconnect: () => { ws.close(); }, + sendBinary: (transferId: number, data: Uint8Array) => { + const frame = createBinaryFrame(transferId, data); + ws.send(frame); + }, }; resolve(client); diff --git a/packages/bridge/src/image-snapshot.ts b/packages/bridge/src/image-snapshot.ts new file mode 100644 index 0000000..332d40f --- /dev/null +++ b/packages/bridge/src/image-snapshot.ts @@ -0,0 +1,170 @@ +import pixelmatch from 'pixelmatch'; +import { ssim } from 'ssim.js'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { PNG } from 'pngjs'; +import type { FileReference, ImageSnapshotOptions } from './shared.js'; + +type PixelmatchOptions = Parameters[5]; + +const SNAPSHOT_DIR_NAME = '__image_snapshots__'; +const DEFAULT_OPTIONS_FOR_PIXELMATCH: PixelmatchOptions = { + threshold: 0.1, + includeAA: false, + alpha: 0.1, + aaColor: [255, 255, 0], + diffColor: [255, 0, 0], + // @ts-expect-error - this is extracted from the pixelmatch package + diffColorAlt: null, + diffMask: false, +}; + +function maskRegions( + data: Buffer, + imageWidth: number, + regions: Array<{ x: number; y: number; width: number; height: number }> +) { + for (const region of regions) { + const startY = Math.max(0, region.y); + const endY = Math.min( + Math.floor(data.length / 4 / imageWidth), + region.y + region.height + ); + const startX = Math.max(0, region.x); + const endX = Math.min(imageWidth, region.x + region.width); + + for (let y = startY; y < endY; y++) { + for (let x = startX; x < endX; x++) { + const idx = (imageWidth * y + x) << 2; + data[idx] = 0; + data[idx + 1] = 0; + data[idx + 2] = 0; + data[idx + 3] = 0; + } + } + } +} + +export const matchImageSnapshot = async ( + screenshot: FileReference, + testFilePath: string, + options: ImageSnapshotOptions, + platformName: string +) => { + const pixelmatchOptions = { + ...DEFAULT_OPTIONS_FOR_PIXELMATCH, + ...options, + }; + const receivedPath = screenshot.path; + + try { + await fs.access(receivedPath); + } catch { + throw new Error(`Screenshot file not found at ${receivedPath}`); + } + + const receivedBuffer = await fs.readFile(receivedPath); + + // Create __image_snapshots__ directory in same directory as test file + const testDir = path.dirname(testFilePath); + const snapshotsDir = path.join(testDir, SNAPSHOT_DIR_NAME, platformName); + + const snapshotName = `${options.name}.png`; + const snapshotPath = path.join(snapshotsDir, snapshotName); + + await fs.mkdir(snapshotsDir, { recursive: true }); + + try { + await fs.access(snapshotPath); + } catch { + // First time - create snapshot + await fs.writeFile(snapshotPath, receivedBuffer); + return { + pass: true, + message: `Snapshot created at ${snapshotPath}`, + }; + } + + const [receivedBufferAgain, snapshotBuffer] = await Promise.all([ + fs.readFile(receivedPath), + fs.readFile(snapshotPath), + ]); + const img1 = PNG.sync.read(receivedBufferAgain); + const img2 = PNG.sync.read(snapshotBuffer); + const { width, height } = img1; + const diff = new PNG({ width, height }); + + if (img1.width !== img2.width || img1.height !== img2.height) { + return { + pass: false, + message: `Images have different dimensions. Received image width: ${img1.width}, height: ${img1.height}. Snapshot image width: ${img2.width}, height: ${img2.height}.`, + }; + } + + if (options.ignoreRegions) { + maskRegions(img1.data, width, options.ignoreRegions); + maskRegions(img2.data, width, options.ignoreRegions); + } + + let pass = false; + let message = ''; + // Always calculate pixel differences for visual diff + const differences = pixelmatch( + img1.data, + img2.data, + diff.data, + width, + height, + pixelmatchOptions + ); + + if (options.comparisonMethod === 'ssim') { + const img1Data = { + data: new Uint8ClampedArray(img1.data), + width: img1.width, + height: img1.height, + }; + const img2Data = { + data: new Uint8ClampedArray(img2.data), + width: img2.width, + height: img2.height, + }; + const { mssim } = ssim(img1Data, img2Data); + const threshold = options.ssimThreshold ?? 0.95; + pass = mssim >= threshold; + message = pass + ? `Images match (SSIM: ${mssim})` + : `SSIM score ${mssim} is less than threshold ${threshold}`; + } else { + const failureThreshold = options.failureThreshold ?? 0; + const failureThresholdType = options.failureThresholdType ?? 'pixel'; + + if (failureThresholdType === 'percent') { + const totalPixels = width * height; + const percentage = differences / totalPixels; + pass = percentage <= failureThreshold; + } else { + pass = differences <= failureThreshold; + } + + message = pass ? 'Images match' : `Images differ by ${differences} pixels.`; + } + + // Save diff and actual images when test fails + if (!pass) { + const diffFileName = `${snapshotName.replace('.png', '')}-diff.png`; + const diffPath = path.join(snapshotsDir, diffFileName); + await fs.writeFile(diffPath, PNG.sync.write(diff)); + + const actualFileName = `${snapshotName.replace('.png', '')}-actual.png`; + const actualPath = path.join(snapshotsDir, actualFileName); + await fs.writeFile(actualPath, receivedBuffer); + + message += ` Diff saved at ${diffPath}. Actual image saved at ${actualPath}.`; + } + + return { + pass, + message, + }; +}; diff --git a/packages/bridge/src/index.ts b/packages/bridge/src/index.ts index 8180878..74a8ed6 100644 --- a/packages/bridge/src/index.ts +++ b/packages/bridge/src/index.ts @@ -1 +1,2 @@ export * from './shared.js'; +export * from './binary-transfer.js'; diff --git a/packages/bridge/src/server.ts b/packages/bridge/src/server.ts index af45378..ab4b97f 100644 --- a/packages/bridge/src/server.ts +++ b/packages/bridge/src/server.ts @@ -2,18 +2,31 @@ import { WebSocketServer, type WebSocket } from 'ws'; import { type BirpcGroup, createBirpcGroup } from 'birpc'; import { logger } from '@react-native-harness/tools'; import { EventEmitter } from 'node:events'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { randomUUID } from 'node:crypto'; +import { BinaryStore, parseBinaryFrame } from './binary-transfer.js'; import type { BridgeServerFunctions, BridgeClientFunctions, DeviceDescriptor, BridgeEvents, + ImageSnapshotOptions, + HarnessContext, + BinaryDataReference, + FileReference, } from './shared.js'; import { deserialize, serialize } from './serializer.js'; import { DeviceNotRespondingError } from './errors.js'; +import { matchImageSnapshot } from './image-snapshot.js'; + +export { DeviceNotRespondingError } from './errors.js'; export type BridgeServerOptions = { port: number; timeout?: number; + context: HarnessContext; }; export type BridgeServerEvents = { @@ -43,6 +56,7 @@ export type BridgeServer = { export const getBridgeServer = async ({ port, timeout, + context, }: BridgeServerOptions): Promise => { const wss = await new Promise((resolve) => { const server = new WebSocketServer({ port, host: '0.0.0.0' }, () => { @@ -51,19 +65,65 @@ export const getBridgeServer = async ({ }); const emitter = new EventEmitter(); const clients = new Set(); + const binaryStore = new BinaryStore(); + + const baseFunctions: BridgeServerFunctions = { + reportReady: (device) => { + emitter.emit('ready', device); + }, + emitEvent: (_, data) => { + emitter.emit('event', data); + }, + 'device.screenshot.receive': async ( + reference: BinaryDataReference, + metadata: { width: number; height: number } + ) => { + const data = binaryStore.get(reference.transferId); + if (!data) { + throw new Error( + `Binary data for transfer ${reference.transferId} not found or expired` + ); + } + + // Clean up from store + binaryStore.delete(reference.transferId); + + // Write to temp file + const tempFile = path.join( + os.tmpdir(), + `harness-screenshot-${randomUUID()}.png` + ); + await fs.writeFile(tempFile, data); + + return { + path: tempFile, + width: metadata.width, + height: metadata.height, + }; + }, + 'test.matchImageSnapshot': async ( + screenshot: FileReference, + testPath: string, + options: ImageSnapshotOptions + ) => { + return await matchImageSnapshot( + screenshot, + testPath, + options, + context.platform.name + ); + }, + }; const group = createBirpcGroup( - { - reportReady: (device) => { - emitter.emit('ready', device); - }, - emitEvent: (_, data) => { - emitter.emit('event', data); - }, - } satisfies BridgeServerFunctions, + baseFunctions, [], { timeout, + onFunctionError: (error, functionName, args) => { + console.error('Function error', error, functionName, args); + throw error; + }, onTimeoutError(functionName, args) { throw new DeviceNotRespondingError(functionName, args); }, @@ -84,10 +144,23 @@ export const getBridgeServer = async ({ channels.push({ post: (data) => ws.send(data), on: (handler) => { - ws.on('message', (event: Buffer | ArrayBuffer | Buffer[]) => { - const message = event.toString(); - handler(message); - }); + ws.on( + 'message', + (event: Buffer | ArrayBuffer | Buffer[], isBinary: boolean) => { + if (isBinary) { + const uint8Array = new Uint8Array(event as any); + try { + const { transferId, data } = parseBinaryFrame(uint8Array); + binaryStore.add(transferId, data); + return; + } catch (error) { + logger.warn('Failed to parse binary frame', error); + } + } + const message = event.toString(); + handler(message); + } + ); }, serialize, deserialize, @@ -98,6 +171,7 @@ export const getBridgeServer = async ({ const dispose = () => { wss.close(); emitter.removeAllListeners(); + binaryStore.dispose(); }; return { diff --git a/packages/bridge/src/shared.ts b/packages/bridge/src/shared.ts index 86ed98a..d5695c8 100644 --- a/packages/bridge/src/shared.ts +++ b/packages/bridge/src/shared.ts @@ -4,6 +4,73 @@ import type { } from './shared/test-runner.js'; import type { TestCollectorEvents } from './shared/test-collector.js'; import type { BundlerEvents } from './shared/bundler.js'; +import type { HarnessPlatform } from '@react-native-harness/platforms'; + +export type FileReference = { + path: string; +}; + +export type ImageSnapshotOptions = { + /** + * The name of the snapshot. This is required and must be unique within the test. + */ + name: string; + /** + * Comparison algorithm to use. + * @default 'pixelmatch' + */ + comparisonMethod?: 'pixelmatch' | 'ssim'; + /** + * Matching threshold for pixelmatch, ranges from 0 to 1. Smaller values make the comparison more sensitive. + * @default 0.1 + */ + threshold?: number; + /** + * Threshold for test failure. + */ + failureThreshold?: number; + /** + * Type of failure threshold. + * @default 'pixel' + */ + failureThresholdType?: 'pixel' | 'percent'; + /** + * Minimum similarity score for SSIM comparison (0-1). + * @default 0.95 + */ + ssimThreshold?: number; + /** + * Regions to ignore during comparison. + */ + ignoreRegions?: Array<{ + x: number; + y: number; + width: number; + height: number; + }>; + /** + * If true, disables detecting and ignoring anti-aliased pixels. + * @default false + */ + includeAA?: boolean; + /** + * Blending factor of unchanged pixels in the diff output. + * Ranges from 0 for pure white to 1 for original brightness + * @default 0.1 + */ + alpha?: number; + /** + * The color of differing pixels in the diff output. + * @default [255, 0, 0] + */ + diffColor?: [number, number, number]; + /** + * An alternative color to use for dark on light differences to differentiate between "added" and "removed" parts. + * If not provided, all differing pixels use the color specified by `diffColor`. + * @default null + */ + diffColorAlt?: [number, number, number]; +}; export type { TestCollectorEvents, @@ -36,7 +103,6 @@ export type { SetupFileBundlingFailedEvent, BundlerEvents, } from './shared/bundler.js'; -export { DeviceNotRespondingError } from './errors.js'; export type DeviceDescriptor = { platform: 'ios' | 'android' | 'vega'; @@ -60,19 +126,43 @@ export type TestExecutionOptions = { testNamePattern?: string; setupFiles?: string[]; setupFilesAfterEnv?: string[]; + runner: string; }; export type BridgeClientFunctions = { runTests: ( path: string, - options?: TestExecutionOptions + options: TestExecutionOptions ) => Promise; }; +export type BinaryDataReference = { + type: 'binary'; + transferId: number; + size: number; + mimeType: 'image/png'; +}; + +export type ScreenshotData = BinaryDataReference; + export type BridgeServerFunctions = { reportReady: (device: DeviceDescriptor) => void; emitEvent: ( event: TEvent['type'], data: TEvent ) => void; + 'device.screenshot.receive': ( + reference: BinaryDataReference, + metadata: { width: number; height: number } + ) => Promise; + 'test.matchImageSnapshot': ( + screenshot: FileReference, + testPath: string, + options: ImageSnapshotOptions, + runner: string + ) => Promise<{ pass: boolean; message: string }>; +}; + +export type HarnessContext = { + platform: HarnessPlatform; }; diff --git a/packages/bridge/tsconfig.json b/packages/bridge/tsconfig.json index a97d426..56b5cd9 100644 --- a/packages/bridge/tsconfig.json +++ b/packages/bridge/tsconfig.json @@ -6,6 +6,9 @@ { "path": "../tools" }, + { + "path": "../platforms" + }, { "path": "./tsconfig.lib.json" } diff --git a/packages/bridge/tsconfig.lib.json b/packages/bridge/tsconfig.lib.json index 66282ce..88461d0 100644 --- a/packages/bridge/tsconfig.lib.json +++ b/packages/bridge/tsconfig.lib.json @@ -14,6 +14,9 @@ "references": [ { "path": "../tools/tsconfig.lib.json" + }, + { + "path": "../platforms/tsconfig.lib.json" } ] } diff --git a/packages/config/src/types.ts b/packages/config/src/types.ts index 66937c6..e0580cc 100644 --- a/packages/config/src/types.ts +++ b/packages/config/src/types.ts @@ -1,12 +1,24 @@ import { z } from 'zod'; +const RunnerSchema = z.object({ + name: z + .string() + .min(1, 'Runner name is required') + .regex( + /^[a-zA-Z0-9._-]+$/, + 'Runner name can only contain alphanumeric characters, dots, underscores, and hyphens' + ), + config: z.record(z.any()), + runner: z.string(), +}); + export const ConfigSchema = z .object({ entryPoint: z.string().min(1, 'Entry point is required'), appRegistryComponentName: z .string() .min(1, 'App registry component name is required'), - runners: z.array(z.any()).min(1, 'At least one runner is required'), + runners: z.array(RunnerSchema).min(1, 'At least one runner is required'), defaultRunner: z.string().optional(), webSocketPort: z.number().optional().default(3001), bridgeTimeout: z diff --git a/packages/github-action/src/android/action.yml b/packages/github-action/src/android/action.yml index 820df9e..5c4e5a4 100644 --- a/packages/github-action/src/android/action.yml +++ b/packages/github-action/src/android/action.yml @@ -12,6 +12,11 @@ inputs: description: The project root directory required: false type: string + uploadVisualTestArtifacts: + description: Whether to upload visual test diff and actual images as artifacts + required: false + type: boolean + default: 'true' runs: using: 'composite' steps: @@ -112,3 +117,12 @@ runs: echo $(pwd) adb install -r ${{ inputs.app }} pnpm react-native-harness --harnessRunner ${{ inputs.runner }} + - name: Upload visual test artifacts + if: always() && inputs.uploadVisualTestArtifacts == 'true' + uses: actions/upload-artifact@v4 + with: + name: visual-test-diffs-android + path: | + ${{ inputs.projectRoot }}/**/__image_snapshots__/**/*-diff.png + ${{ inputs.projectRoot }}/**/__image_snapshots__/**/*-actual.png + if-no-files-found: ignore diff --git a/packages/github-action/src/ios/action.yml b/packages/github-action/src/ios/action.yml index 173c923..706d573 100644 --- a/packages/github-action/src/ios/action.yml +++ b/packages/github-action/src/ios/action.yml @@ -12,6 +12,11 @@ inputs: description: The project root directory required: false type: string + uploadVisualTestArtifacts: + description: Whether to upload visual test diff and actual images as artifacts + required: false + type: boolean + default: 'true' runs: using: 'composite' steps: @@ -40,3 +45,12 @@ runs: working-directory: ${{ inputs.projectRoot }} run: | pnpm react-native-harness --harnessRunner ${{ inputs.runner }} + - name: Upload visual test artifacts + if: always() && inputs.uploadVisualTestArtifacts == 'true' + uses: actions/upload-artifact@v4 + with: + name: visual-test-diffs-ios + path: | + ${{ inputs.projectRoot }}/**/__image_snapshots__/**/*-diff.png + ${{ inputs.projectRoot }}/**/__image_snapshots__/**/*-actual.png + if-no-files-found: ignore diff --git a/packages/jest/src/harness.ts b/packages/jest/src/harness.ts index 536516a..e1fd62b 100644 --- a/packages/jest/src/harness.ts +++ b/packages/jest/src/harness.ts @@ -2,7 +2,11 @@ import { getBridgeServer, BridgeServer, } from '@react-native-harness/bridge/server'; -import { BridgeClientFunctions } from '@react-native-harness/bridge'; +import { + HarnessContext, + TestExecutionOptions, + TestSuiteResult, +} from '@react-native-harness/bridge'; import { HarnessPlatform, HarnessPlatformRunner, @@ -16,8 +20,14 @@ import { InitializationTimeoutError, MaxAppRestartsError } from './errors.js'; import { Config as HarnessConfig } from '@react-native-harness/config'; import { createCrashMonitor, CrashMonitor } from './crash-monitor.js'; +export type HarnessRunTestsOptions = Exclude; + export type Harness = { - runTests: BridgeClientFunctions['runTests']; + context: HarnessContext; + runTests: ( + path: string, + options: HarnessRunTestsOptions + ) => Promise; restart: () => Promise; dispose: () => Promise; crashMonitor: CrashMonitor; @@ -118,6 +128,10 @@ const getHarnessInternal = async ( projectRoot: string, signal: AbortSignal ): Promise => { + const context: HarnessContext = { + platform, + }; + const [metroInstance, platformInstance, serverBridge] = await Promise.all([ getMetroInstance({ projectRoot, harnessConfig: config }, signal), import(platform.runner).then((module) => @@ -126,6 +140,7 @@ const getHarnessInternal = async ( getBridgeServer({ port: config.webSocketPort, timeout: config.bridgeTimeout, + context, }), ]); @@ -174,6 +189,7 @@ const getHarnessInternal = async ( }); return { + context, runTests: async (path, options) => { const client = serverBridge.rpc.clients.at(-1); @@ -181,7 +197,10 @@ const getHarnessInternal = async ( throw new Error('No client found'); } - return await client.runTests(path, options); + return await client.runTests(path, { + ...options, + runner: platform.runner, + }); }, restart, dispose, diff --git a/packages/jest/src/index.ts b/packages/jest/src/index.ts index aeae142..b087d39 100644 --- a/packages/jest/src/index.ts +++ b/packages/jest/src/index.ts @@ -16,7 +16,7 @@ import { setup } from './setup.js'; import { teardown } from './teardown.js'; import { HarnessError } from '@react-native-harness/tools'; import { getErrorMessage } from './logs.js'; -import { DeviceNotRespondingError } from '@react-native-harness/bridge'; +import { DeviceNotRespondingError } from '@react-native-harness/bridge/server'; import { NativeCrashError } from './errors.js'; class CancelRun extends Error { diff --git a/packages/jest/src/run.ts b/packages/jest/src/run.ts index 15f3d1d..8416827 100644 --- a/packages/jest/src/run.ts +++ b/packages/jest/src/run.ts @@ -88,6 +88,7 @@ export const runHarnessTestFile: RunHarnessTestFile = async ({ testNamePattern: globalConfig.testNamePattern, setupFiles, setupFilesAfterEnv, + runner: harness.context.platform.runner, }); const end = Date.now(); diff --git a/packages/jest/tsconfig.json b/packages/jest/tsconfig.json index d3866d2..e0031d8 100644 --- a/packages/jest/tsconfig.json +++ b/packages/jest/tsconfig.json @@ -6,9 +6,6 @@ { "path": "../cli" }, - { - "path": "../bundler-metro" - }, { "path": "../tools" }, @@ -18,6 +15,9 @@ { "path": "../config" }, + { + "path": "../bundler-metro" + }, { "path": "../bridge" }, diff --git a/packages/platform-ios/src/instance.ts b/packages/platform-ios/src/instance.ts index 46711b2..6e4098f 100644 --- a/packages/platform-ios/src/instance.ts +++ b/packages/platform-ios/src/instance.ts @@ -41,15 +41,6 @@ export const getAppleSimulatorPlatformInstance = async ( throw new Error('Simulator is not booted'); } - const isAvailable = await simctl.isAppInstalled(udid, config.bundleId); - - if (!isAvailable) { - throw new AppNotInstalledError( - config.bundleId, - getDeviceName(config.device) - ); - } - return { startApp: async () => { await simctl.startApp(udid, config.bundleId); diff --git a/packages/platform-ios/src/utils.ts b/packages/platform-ios/src/utils.ts index 4bd591f..0a24e06 100644 --- a/packages/platform-ios/src/utils.ts +++ b/packages/platform-ios/src/utils.ts @@ -1,5 +1,4 @@ import { isAppleDeviceSimulator, type AppleDevice } from './config.js'; - export const getDeviceName = (device: AppleDevice): string => { if (isAppleDeviceSimulator(device)) { return `${device.name} (${device.systemVersion}) (simulator)`; diff --git a/packages/platform-ios/src/xcrun/simctl.ts b/packages/platform-ios/src/xcrun/simctl.ts index 71588c7..df532d7 100644 --- a/packages/platform-ios/src/xcrun/simctl.ts +++ b/packages/platform-ios/src/xcrun/simctl.ts @@ -138,3 +138,11 @@ export const isAppRunning = async ( return false; } }; + +export const screenshot = async ( + udid: string, + destination: string +): Promise => { + await spawn('xcrun', ['simctl', 'io', udid, 'screenshot', destination]); + return destination; +}; diff --git a/packages/platform-ios/tsconfig.tsbuildinfo b/packages/platform-ios/tsconfig.tsbuildinfo new file mode 100644 index 0000000..7cf2f68 --- /dev/null +++ b/packages/platform-ios/tsconfig.tsbuildinfo @@ -0,0 +1 @@ +{"fileNames":[],"fileInfos":[],"root":[],"options":{"composite":true,"declarationMap":true,"emitDeclarationOnly":true,"importHelpers":true,"module":199,"noEmitOnError":true,"noFallthroughCasesInSwitch":true,"noImplicitOverride":true,"noImplicitReturns":true,"noUnusedLocals":true,"skipLibCheck":true,"strict":true,"target":9},"version":"5.9.3"} \ No newline at end of file diff --git a/packages/platforms/src/errors.ts b/packages/platforms/src/errors.ts index 9461fb2..8a4c21e 100644 --- a/packages/platforms/src/errors.ts +++ b/packages/platforms/src/errors.ts @@ -14,3 +14,17 @@ export class DeviceNotFoundError extends Error { this.name = 'DeviceNotFoundError'; } } + +export class DependencyNotFoundError extends Error { + constructor( + public readonly dependencyName: string, + public readonly installInstructions?: string + ) { + super( + `Dependency "${dependencyName}" not found.${ + installInstructions ? ` ${installInstructions}` : '' + }` + ); + this.name = 'DependencyNotFoundError'; + } +} \ No newline at end of file diff --git a/packages/platforms/src/index.ts b/packages/platforms/src/index.ts index 79197e8..6e08ee4 100644 --- a/packages/platforms/src/index.ts +++ b/packages/platforms/src/index.ts @@ -1,2 +1,6 @@ export type { HarnessPlatform, HarnessPlatformRunner } from './types.js'; -export { AppNotInstalledError, DeviceNotFoundError } from './errors.js'; +export { + AppNotInstalledError, + DeviceNotFoundError, + DependencyNotFoundError, +} from './errors.js'; diff --git a/packages/runtime/package.json b/packages/runtime/package.json index ec10959..77277ff 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -1,10 +1,19 @@ { "name": "@react-native-harness/runtime", + "description": "The core test runtime that executes on React Native devices, providing Jest-compatible APIs (describe, it, expect) and managing test collection, execution, and result reporting in native environments.", "version": "1.0.0-alpha.21", "type": "module", "main": "./dist/index.js", "module": "./dist/index.js", "types": "./dist/index.d.ts", + "files": [ + "src", + "lib", + "!**/__tests__", + "!**/__fixtures__", + "!**/__mocks__", + "!**/.*" + ], "exports": { "./package.json": "./package.json", ".": { @@ -42,5 +51,14 @@ "react": "*", "react-native": "*" }, + "author": { + "name": "Szymon Chmal", + "email": "szymon.chmal@callstack.com" + }, + "homepage": "https://github.com/callstackincubator/react-native-harness", + "repository": { + "type": "git", + "url": "https://github.com/callstackincubator/react-native-harness.git" + }, "license": "MIT" } diff --git a/packages/runtime/src/__tests__/expect.test.ts b/packages/runtime/src/__tests__/expect.test.ts deleted file mode 100644 index dd06f85..0000000 --- a/packages/runtime/src/__tests__/expect.test.ts +++ /dev/null @@ -1,627 +0,0 @@ -import { describe, test } from 'vitest'; -import { expect } from '../expect/index.js'; - -describe('expect - Basic Matchers', () => { - describe('toBe', () => { - test('should pass for identical primitive values', () => { - expect(1).toBe(1); - expect('hello').toBe('hello'); - expect(true).toBe(true); - expect(null).toBe(null); - expect(undefined).toBe(undefined); - }); - - test('should fail for different primitive values', () => { - expect(() => expect(1).toBe(2)).toThrow(); - expect(() => expect('hello').toBe('world')).toThrow(); - expect(() => expect(true).toBe(false)).toThrow(); - }); - - test('should fail for objects with same content', () => { - expect(() => expect({}).toBe({})).toThrow(); - expect(() => expect([]).toBe([])).toThrow(); - }); - }); - - describe('toEqual', () => { - test('should pass for equal primitive values', () => { - expect(1).toEqual(1); - expect('hello').toEqual('hello'); - expect(true).toEqual(true); - }); - - test('should pass for equal objects', () => { - expect({ a: 1, b: 2 }).toEqual({ a: 1, b: 2 }); - expect([1, 2, 3]).toEqual([1, 2, 3]); - expect({ nested: { value: 'test' } }).toEqual({ - nested: { value: 'test' }, - }); - }); - - test('should fail for unequal objects', () => { - expect(() => expect({ a: 1 }).toEqual({ a: 2 })).toThrow(); - expect(() => expect([1, 2]).toEqual([1, 3])).toThrow(); - }); - }); - - describe('toStrictEqual', () => { - test('should pass for strictly equal values', () => { - expect({ a: 1 }).toStrictEqual({ a: 1 }); - expect([1, 2, 3]).toStrictEqual([1, 2, 3]); - }); - - test('should fail for objects with undefined vs missing properties', () => { - expect(() => - expect({ a: 1, b: undefined }).toStrictEqual({ a: 1 }) - ).toThrow(); - }); - - test('should differentiate between sparse and dense arrays', () => { - const sparse = [1, , 3]; // eslint-disable-line no-sparse-arrays - const dense = [1, undefined, 3]; - expect(() => expect(sparse).toStrictEqual(dense)).toThrow(); - }); - }); -}); - -describe('expect - Truthiness', () => { - describe('toBeTruthy', () => { - test('should pass for truthy values', () => { - expect(true).toBeTruthy(); - expect(1).toBeTruthy(); - expect('hello').toBeTruthy(); - expect({}).toBeTruthy(); - expect([]).toBeTruthy(); - expect(function () { - // noop - }).toBeTruthy(); - }); - - test('should fail for falsy values', () => { - expect(() => expect(false).toBeTruthy()).toThrow(); - expect(() => expect(0).toBeTruthy()).toThrow(); - expect(() => expect('').toBeTruthy()).toThrow(); - expect(() => expect(null).toBeTruthy()).toThrow(); - expect(() => expect(undefined).toBeTruthy()).toThrow(); - expect(() => expect(NaN).toBeTruthy()).toThrow(); - }); - }); - - describe('toBeFalsy', () => { - test('should pass for falsy values', () => { - expect(false).toBeFalsy(); - expect(0).toBeFalsy(); - expect('').toBeFalsy(); - expect(null).toBeFalsy(); - expect(undefined).toBeFalsy(); - expect(NaN).toBeFalsy(); - }); - - test('should fail for truthy values', () => { - expect(() => expect(true).toBeFalsy()).toThrow(); - expect(() => expect(1).toBeFalsy()).toThrow(); - expect(() => expect('hello').toBeFalsy()).toThrow(); - }); - }); -}); - -describe('expect - Numbers', () => { - describe('toBeGreaterThan', () => { - test('should pass when value is greater', () => { - expect(5).toBeGreaterThan(3); - expect(0).toBeGreaterThan(-1); - expect(1.5).toBeGreaterThan(1.4); - }); - - test('should fail when value is equal or less', () => { - expect(() => expect(3).toBeGreaterThan(5)).toThrow(); - expect(() => expect(3).toBeGreaterThan(3)).toThrow(); - }); - }); - - describe('toBeGreaterThanOrEqual', () => { - test('should pass when value is greater or equal', () => { - expect(5).toBeGreaterThanOrEqual(3); - expect(3).toBeGreaterThanOrEqual(3); - expect(0).toBeGreaterThanOrEqual(-1); - }); - - test('should fail when value is less', () => { - expect(() => expect(3).toBeGreaterThanOrEqual(5)).toThrow(); - }); - }); - - describe('toBeLessThan', () => { - test('should pass when value is less', () => { - expect(3).toBeLessThan(5); - expect(-1).toBeLessThan(0); - expect(1.4).toBeLessThan(1.5); - }); - - test('should fail when value is equal or greater', () => { - expect(() => expect(5).toBeLessThan(3)).toThrow(); - expect(() => expect(3).toBeLessThan(3)).toThrow(); - }); - }); - - describe('toBeLessThanOrEqual', () => { - test('should pass when value is less or equal', () => { - expect(3).toBeLessThanOrEqual(5); - expect(3).toBeLessThanOrEqual(3); - expect(-1).toBeLessThanOrEqual(0); - }); - - test('should fail when value is greater', () => { - expect(() => expect(5).toBeLessThanOrEqual(3)).toThrow(); - }); - }); - - describe('toBeCloseTo', () => { - test('should pass for values close to each other', () => { - expect(0.2 + 0.1).toBeCloseTo(0.3); - expect(0.2 + 0.1).toBeCloseTo(0.3, 5); - }); - - test('should fail for values far apart', () => { - expect(() => expect(0.1).toBeCloseTo(0.2)).toThrow(); - }); - - test('should respect precision parameter', () => { - expect(1.23456).toBeCloseTo(1.23, 2); - expect(() => expect(1.23456).toBeCloseTo(1.23, 4)).toThrow(); - }); - }); - - describe('toBeNaN', () => { - test('should pass for NaN', () => { - expect(NaN).toBeNaN(); - expect(Number('not a number')).toBeNaN(); - expect(0 / 0).toBeNaN(); - }); - - test('should fail for numbers', () => { - expect(() => expect(1).toBeNaN()).toThrow(); - expect(() => expect(0).toBeNaN()).toThrow(); - expect(() => expect(Infinity).toBeNaN()).toThrow(); - }); - }); -}); - -describe('expect - Strings', () => { - describe('toMatch', () => { - test('should pass for matching strings', () => { - expect('hello world').toMatch('world'); - expect('hello world').toMatch(/world/); - expect('hello world').toMatch(/^hello/); - }); - - test('should fail for non-matching strings', () => { - expect(() => expect('hello world').toMatch('xyz')).toThrow(); - expect(() => expect('hello world').toMatch(/xyz/)).toThrow(); - }); - }); - - describe('toContain', () => { - test('should pass for strings containing substring', () => { - expect('hello world').toContain('world'); - expect('hello world').toContain('hello'); - expect('hello world').toContain(' '); - }); - - test('should fail for strings not containing substring', () => { - expect(() => expect('hello world').toContain('xyz')).toThrow(); - }); - }); - - describe('toHaveLength', () => { - test('should pass for correct string length', () => { - expect('hello').toHaveLength(5); - expect('').toHaveLength(0); - }); - - test('should fail for incorrect string length', () => { - expect(() => expect('hello').toHaveLength(3)).toThrow(); - }); - }); -}); - -describe('expect - Arrays and Iterables', () => { - describe('toContain for arrays', () => { - test('should pass for arrays containing value', () => { - expect([1, 2, 3]).toContain(2); - expect(['a', 'b', 'c']).toContain('b'); - }); - - test('should fail for arrays not containing value', () => { - expect(() => expect([1, 2, 3]).toContain(4)).toThrow(); - }); - }); - - describe('toContainEqual', () => { - test('should pass for arrays containing equal object', () => { - expect([{ id: 1 }, { id: 2 }]).toContainEqual({ id: 1 }); - expect([ - [1, 2], - [3, 4], - ]).toContainEqual([1, 2]); - }); - - test('should fail for arrays not containing equal object', () => { - expect(() => expect([{ id: 1 }]).toContainEqual({ id: 2 })).toThrow(); - }); - }); - - describe('toHaveLength for arrays', () => { - test('should pass for correct array length', () => { - expect([1, 2, 3]).toHaveLength(3); - expect([]).toHaveLength(0); - }); - - test('should fail for incorrect array length', () => { - expect(() => expect([1, 2, 3]).toHaveLength(2)).toThrow(); - }); - }); -}); - -describe('expect - Objects', () => { - describe('toHaveProperty', () => { - test('should pass for existing properties', () => { - const obj = { a: 1, b: { c: 2 } }; - expect(obj).toHaveProperty('a'); - expect(obj).toHaveProperty('b.c'); - expect(obj).toHaveProperty(['b', 'c']); - }); - - test('should pass for existing properties with values', () => { - const obj = { a: 1, b: { c: 2 } }; - expect(obj).toHaveProperty('a', 1); - expect(obj).toHaveProperty('b.c', 2); - }); - - test('should fail for non-existing properties', () => { - const obj = { a: 1 }; - expect(() => expect(obj).toHaveProperty('b')).toThrow(); - expect(() => expect(obj).toHaveProperty('a.b')).toThrow(); - }); - - test('should fail for wrong property values', () => { - const obj = { a: 1 }; - expect(() => expect(obj).toHaveProperty('a', 2)).toThrow(); - }); - }); - - describe('toMatchObject', () => { - test('should pass for matching object subset', () => { - const obj = { a: 1, b: 2, c: 3 }; - expect(obj).toMatchObject({ a: 1, b: 2 }); - expect(obj).toMatchObject({ a: 1 }); - }); - - test('should pass for nested object matching', () => { - const obj = { a: { b: { c: 1 } }, d: 2 }; - expect(obj).toMatchObject({ a: { b: { c: 1 } } }); - }); - - test('should fail for non-matching properties', () => { - const obj = { a: 1, b: 2 }; - expect(() => expect(obj).toMatchObject({ a: 2 })).toThrow(); - expect(() => expect(obj).toMatchObject({ c: 3 })).toThrow(); - }); - }); -}); - -describe('expect - Type Checking', () => { - describe('toBeInstanceOf', () => { - test('should pass for correct instances', () => { - expect(new Date()).toBeInstanceOf(Date); - expect([]).toBeInstanceOf(Array); - expect(new Error()).toBeInstanceOf(Error); - expect(/regex/).toBeInstanceOf(RegExp); - }); - - test('should fail for incorrect instances', () => { - expect(() => expect('string').toBeInstanceOf(Date)).toThrow(); - expect(() => expect(123).toBeInstanceOf(String)).toThrow(); - }); - }); - - describe('toBeTypeOf', () => { - test('should pass for correct types', () => { - expect('hello').toBeTypeOf('string'); - expect(123).toBeTypeOf('number'); - expect(true).toBeTypeOf('boolean'); - expect(undefined).toBeTypeOf('undefined'); - expect(Symbol('test')).toBeTypeOf('symbol'); - expect(() => { - // noop - }).toBeTypeOf('function'); - expect({}).toBeTypeOf('object'); - }); - - test('should fail for incorrect types', () => { - expect(() => expect('hello').toBeTypeOf('number')).toThrow(); - expect(() => expect(123).toBeTypeOf('string')).toThrow(); - }); - }); - - describe('toBeDefined', () => { - test('should pass for defined values', () => { - expect(0).toBeDefined(); - expect('').toBeDefined(); - expect(false).toBeDefined(); - expect(null).toBeDefined(); - expect({}).toBeDefined(); - }); - - test('should fail for undefined', () => { - expect(() => expect(undefined).toBeDefined()).toThrow(); - }); - }); - - describe('toBeUndefined', () => { - test('should pass for undefined', () => { - expect(undefined).toBeUndefined(); - let uninitialized; - expect(uninitialized).toBeUndefined(); - }); - - test('should fail for defined values', () => { - expect(() => expect(null).toBeUndefined()).toThrow(); - expect(() => expect(0).toBeUndefined()).toThrow(); - }); - }); - - describe('toBeNull', () => { - test('should pass for null', () => { - expect(null).toBeNull(); - }); - - test('should fail for non-null values', () => { - expect(() => expect(undefined).toBeNull()).toThrow(); - expect(() => expect(0).toBeNull()).toThrow(); - }); - }); -}); - -describe('expect - Exceptions', () => { - describe('toThrow', () => { - test('should pass for functions that throw', () => { - expect(() => { - throw new Error('test error'); - }).toThrow(); - - expect(() => { - throw new Error('test error'); - }).toThrow('test error'); - - expect(() => { - throw new Error('test error'); - }).toThrow(/test/); - }); - - test('should pass for specific error types', () => { - expect(() => { - throw new TypeError('type error'); - }).toThrow(TypeError); - - expect(() => { - throw new RangeError('range error'); - }).toThrow(RangeError); - }); - - test('should fail for functions that do not throw', () => { - expect(() => - expect(() => { - // noop - }).toThrow() - ).toThrow(); - }); - - test('should fail for wrong error message', () => { - expect(() => { - expect(() => { - throw new Error('actual message'); - }).toThrow('expected message'); - }).toThrow(); - }); - - test('should fail for wrong error type', () => { - expect(() => { - expect(() => { - throw new Error('test'); - }).toThrow(TypeError); - }).toThrow(); - }); - }); - - describe('toThrowError', () => { - test('should be alias for toThrow', () => { - expect(() => { - throw new Error('test'); - }).toThrowError(); - - expect(() => { - throw new Error('test'); - }).toThrowError('test'); - }); - }); -}); - -describe('expect - Asymmetric Matchers', () => { - describe('expect.any', () => { - test('should match any instance of constructor', () => { - expect('hello').toEqual(expect.any(String)); - expect(123).toEqual(expect.any(Number)); - expect({}).toEqual(expect.any(Object)); - expect([]).toEqual(expect.any(Array)); - expect(new Date()).toEqual(expect.any(Date)); - }); - - test('should work in object matching', () => { - expect({ id: 1, name: 'test' }).toEqual({ - id: expect.any(Number), - name: expect.any(String), - }); - }); - }); - - describe('expect.anything', () => { - test('should match any defined value', () => { - expect('hello').toEqual(expect.anything()); - expect(123).toEqual(expect.anything()); - expect({}).toEqual(expect.anything()); - expect([]).toEqual(expect.anything()); - }); - - test('should not match undefined', () => { - expect(() => expect(undefined).toEqual(expect.anything())).toThrow(); - }); - }); - - describe('expect.arrayContaining', () => { - test('should match arrays containing all specified elements', () => { - expect([1, 2, 3, 4]).toEqual(expect.arrayContaining([2, 3])); - expect(['a', 'b', 'c']).toEqual(expect.arrayContaining(['b'])); - }); - - test('should fail when array does not contain all elements', () => { - expect(() => - expect([1, 2, 3]).toEqual(expect.arrayContaining([4, 5])) - ).toThrow(); - }); - }); - - describe('expect.objectContaining', () => { - test('should match objects containing specified properties', () => { - expect({ a: 1, b: 2, c: 3 }).toEqual( - expect.objectContaining({ a: 1, b: 2 }) - ); - expect({ name: 'test', id: 1 }).toEqual( - expect.objectContaining({ name: 'test' }) - ); - }); - - test('should fail when object does not contain specified properties', () => { - expect(() => - expect({ a: 1 }).toEqual(expect.objectContaining({ b: 2 })) - ).toThrow(); - }); - }); - - describe('expect.stringContaining', () => { - test('should match strings containing substring', () => { - expect('hello world').toEqual(expect.stringContaining('world')); - expect('hello world').toEqual(expect.stringContaining('hello')); - }); - - test('should fail when string does not contain substring', () => { - expect(() => - expect('hello world').toEqual(expect.stringContaining('xyz')) - ).toThrow(); - }); - }); - - describe('expect.stringMatching', () => { - test('should match strings matching regex', () => { - expect('hello world').toEqual(expect.stringMatching(/world/)); - expect('hello world').toEqual(expect.stringMatching('world')); - }); - - test('should fail when string does not match pattern', () => { - expect(() => - expect('hello world').toEqual(expect.stringMatching(/xyz/)) - ).toThrow(); - }); - }); -}); - -describe('expect - Negation', () => { - test('should negate basic matchers with .not', () => { - expect(1).not.toBe(2); - expect('hello').not.toEqual('world'); - expect([1, 2, 3]).not.toContain(4); - expect({ a: 1 }).not.toHaveProperty('b'); - }); - - test('should negate truthiness', () => { - expect(false).not.toBeTruthy(); - expect(true).not.toBeFalsy(); - expect(0).not.toBeTruthy(); - }); - - test('should negate type checks', () => { - expect('string').not.toBeTypeOf('number'); - expect(undefined).not.toBeDefined(); - expect(123).not.toBeUndefined(); - }); -}); - -describe('expect - Custom Messages', () => { - test('should support custom error messages', () => { - expect(() => { - expect(1, 'This is a custom message').toBe(2); - }).toThrow('This is a custom message'); - }); -}); - -describe('expect - Edge Cases', () => { - test('should handle circular references', () => { - const circular: Record = { a: 1 }; - circular.self = circular; - - const circular2: Record = { a: 1 }; - circular2.self = circular2; - - expect(circular).toEqual(circular2); - }); - - test('should handle sparse arrays', () => { - const sparse1 = [1, , 3]; // eslint-disable-line no-sparse-arrays - const sparse2 = [1, , 3]; // eslint-disable-line no-sparse-arrays - expect(sparse1).toEqual(sparse2); - }); - - test('should handle special number values', () => { - expect(Infinity).toBe(Infinity); - expect(-Infinity).toBe(-Infinity); - expect(0).toBe(0); - expect(-0).toBe(-0); - }); - - test('should handle Date objects', () => { - const date1 = new Date('2023-01-01'); - const date2 = new Date('2023-01-01'); - expect(date1).toEqual(date2); - expect(date1).not.toBe(date2); - }); - - test('should handle RegExp objects', () => { - expect(/abc/g).toEqual(/abc/g); - expect(/abc/g).not.toBe(/abc/g); - }); - - test('should handle Symbol primitives', () => { - const sym1 = Symbol('test'); - const sym2 = Symbol('test'); - expect(sym1).toBe(sym1); - expect(sym1).not.toBe(sym2); - }); - - test('should handle Map objects', () => { - const map1 = new Map([ - ['a', 1], - ['b', 2], - ]); - const map2 = new Map([ - ['a', 1], - ['b', 2], - ]); - expect(map1).toEqual(map2); - }); - - test('should handle Set objects', () => { - const set1 = new Set([1, 2, 3]); - const set2 = new Set([1, 2, 3]); - expect(set1).toEqual(set2); - }); -}); diff --git a/packages/runtime/src/client/factory.ts b/packages/runtime/src/client/factory.ts index 15d5e8b..20726ac 100644 --- a/packages/runtime/src/client/factory.ts +++ b/packages/runtime/src/client/factory.ts @@ -14,6 +14,7 @@ import { getBundler, evaluateModule, Bundler } from '../bundler/index.js'; import { markTestsAsSkippedByName } from '../filtering/index.js'; import { setup } from '../render/setup.js'; import { runSetupFiles } from './setup-files.js'; +import { setClient } from './store.js'; export const getClient = async () => { const client = await getBridgeClient(getWSServer(), { @@ -22,9 +23,11 @@ export const getClient = async () => { }, }); + setClient(client); + client.rpc.$functions.runTests = async ( path: string, - options: TestExecutionOptions = {} + options: TestExecutionOptions ) => { if (store.getState().status === 'running') { throw new Error('Already running tests'); @@ -84,7 +87,11 @@ export const getClient = async () => { ) : collectionResult.testSuite; - const result = await runner.run(processedTestSuite, path); + const result = await runner.run({ + testSuite: processedTestSuite, + testFilePath: path, + runner: options.runner, + }); return result; } finally { collector?.dispose(); diff --git a/packages/runtime/src/client/store.ts b/packages/runtime/src/client/store.ts new file mode 100644 index 0000000..d003793 --- /dev/null +++ b/packages/runtime/src/client/store.ts @@ -0,0 +1,16 @@ +import type { BridgeClient } from '@react-native-harness/bridge/client'; + +let clientInstance: BridgeClient | null = null; + +export const setClient = (client: BridgeClient): void => { + clientInstance = client; +}; + +export const getClientInstance = (): BridgeClient => { + if (!clientInstance) { + throw new Error( + 'Bridge client not initialized. This should not happen in normal operation.' + ); + } + return clientInstance; +}; diff --git a/packages/runtime/src/expect/expect.ts b/packages/runtime/src/expect/expect.ts new file mode 100644 index 0000000..beca251 --- /dev/null +++ b/packages/runtime/src/expect/expect.ts @@ -0,0 +1,127 @@ +// This is adapted version of https://github.com/vitest-dev/vitest/blob/main/packages/vitest/src/integrations/chai/index.ts +// Credits to Vitest team for the original implementation. + +import type { Assertion, ExpectStatic, MatcherState } from '@vitest/expect'; +import { + addCustomEqualityTesters, + ASYMMETRIC_MATCHERS_OBJECT, + customMatchers, + getState, + GLOBAL_EXPECT, + setState, +} from '@vitest/expect'; +import * as chai from 'chai'; + +// Setup additional matchers +import './setup.js'; +import { toMatchImageSnapshot } from './matchers/toMatchImageSnapshot.js'; + +export function createExpect(): ExpectStatic { + const expect = ((value: unknown, message?: string): Assertion => { + const { assertionCalls } = getState(expect); + setState({ assertionCalls: assertionCalls + 1 }, expect); + return chai.expect(value, message) as unknown as Assertion; + }) as ExpectStatic; + Object.assign(expect, chai.expect); + Object.assign( + expect, + globalThis[ASYMMETRIC_MATCHERS_OBJECT as unknown as keyof typeof globalThis] + ); + + expect.getState = () => getState(expect); + expect.setState = (state) => setState(state as Partial, expect); + + // @ts-expect-error global is not typed + const globalState = getState(globalThis[GLOBAL_EXPECT]) || {}; + + setState( + { + // this should also add "snapshotState" that is added conditionally + ...globalState, + assertionCalls: 0, + isExpectingAssertions: false, + isExpectingAssertionsError: null, + expectedAssertionsNumber: null, + expectedAssertionsNumberErrorGen: null, + }, + expect + ); + + // @ts-expect-error untyped + expect.extend = (matchers) => chai.expect.extend(expect, matchers); + // @ts-expect-error untyped + expect.addEqualityTesters = (customTesters) => + addCustomEqualityTesters(customTesters); + + // @ts-expect-error untyped + expect.soft = (...args) => { + // @ts-expect-error private soft access + return expect(...args).withContext({ soft: true }) as Assertion; + }; + + // @ts-expect-error untyped + expect.unreachable = (message?: string) => { + chai.assert.fail( + `expected${message ? ` "${message}" ` : ' '}not to be reached` + ); + }; + + function assertions(expected: number) { + const errorGen = () => + new Error( + `expected number of assertions to be ${expected}, but got ${ + expect.getState().assertionCalls + }` + ); + if (Error.captureStackTrace) { + Error.captureStackTrace(errorGen(), assertions); + } + + expect.setState({ + expectedAssertionsNumber: expected, + expectedAssertionsNumberErrorGen: errorGen, + }); + } + + function hasAssertions() { + const error = new Error('expected any number of assertion, but got none'); + if (Error.captureStackTrace) { + Error.captureStackTrace(error, hasAssertions); + } + + expect.setState({ + isExpectingAssertions: true, + isExpectingAssertionsError: error, + }); + } + + chai.util.addMethod(expect, 'assertions', assertions); + chai.util.addMethod(expect, 'hasAssertions', hasAssertions); + + expect.extend(customMatchers); + expect.extend({ + toMatchImageSnapshot, + }); + + return expect; +} + +const globalExpect: ExpectStatic = createExpect(); + +Object.defineProperty(globalThis, GLOBAL_EXPECT, { + value: globalExpect, + writable: true, + configurable: true, +}); + +export { assert, should } from 'chai'; +export { chai, globalExpect as expect }; + +export type { + Assertion, + AsymmetricMatchersContaining, + DeeplyAllowMatchers, + ExpectStatic, + JestAssertion, + Matchers, +} from '@vitest/expect'; diff --git a/packages/runtime/src/expect/index.ts b/packages/runtime/src/expect/index.ts index adccb02..4bb25dd 100644 --- a/packages/runtime/src/expect/index.ts +++ b/packages/runtime/src/expect/index.ts @@ -1,123 +1 @@ -// This is adapted version of https://github.com/vitest-dev/vitest/blob/main/packages/vitest/src/integrations/chai/index.ts -// Credits to Vitest team for the original implementation. - -import type { Assertion, ExpectStatic, MatcherState } from '@vitest/expect'; -import { - addCustomEqualityTesters, - ASYMMETRIC_MATCHERS_OBJECT, - customMatchers, - getState, - GLOBAL_EXPECT, - setState, -} from '@vitest/expect'; -import * as chai from 'chai'; - -// Setup additional matchers -import './setup.js'; - -export function createExpect(): ExpectStatic { - const expect = ((value: unknown, message?: string): Assertion => { - const { assertionCalls } = getState(expect); - setState({ assertionCalls: assertionCalls + 1 }, expect); - return chai.expect(value, message) as unknown as Assertion; - }) as ExpectStatic; - Object.assign(expect, chai.expect); - Object.assign( - expect, - globalThis[ASYMMETRIC_MATCHERS_OBJECT as unknown as keyof typeof globalThis] - ); - - expect.getState = () => getState(expect); - expect.setState = (state) => setState(state as Partial, expect); - - // @ts-expect-error global is not typed - const globalState = getState(globalThis[GLOBAL_EXPECT]) || {}; - - setState( - { - // this should also add "snapshotState" that is added conditionally - ...globalState, - assertionCalls: 0, - isExpectingAssertions: false, - isExpectingAssertionsError: null, - expectedAssertionsNumber: null, - expectedAssertionsNumberErrorGen: null, - }, - expect - ); - - // @ts-expect-error untyped - expect.extend = (matchers) => chai.expect.extend(expect, matchers); - // @ts-expect-error untyped - expect.addEqualityTesters = (customTesters) => - addCustomEqualityTesters(customTesters); - - // @ts-expect-error untyped - expect.soft = (...args) => { - // @ts-expect-error private soft access - return expect(...args).withContext({ soft: true }) as Assertion; - }; - - // @ts-expect-error untyped - expect.unreachable = (message?: string) => { - chai.assert.fail( - `expected${message ? ` "${message}" ` : ' '}not to be reached` - ); - }; - - function assertions(expected: number) { - const errorGen = () => - new Error( - `expected number of assertions to be ${expected}, but got ${ - expect.getState().assertionCalls - }` - ); - if (Error.captureStackTrace) { - Error.captureStackTrace(errorGen(), assertions); - } - - expect.setState({ - expectedAssertionsNumber: expected, - expectedAssertionsNumberErrorGen: errorGen, - }); - } - - function hasAssertions() { - const error = new Error('expected any number of assertion, but got none'); - if (Error.captureStackTrace) { - Error.captureStackTrace(error, hasAssertions); - } - - expect.setState({ - isExpectingAssertions: true, - isExpectingAssertionsError: error, - }); - } - - chai.util.addMethod(expect, 'assertions', assertions); - chai.util.addMethod(expect, 'hasAssertions', hasAssertions); - - expect.extend(customMatchers); - - return expect; -} - -const globalExpect: ExpectStatic = createExpect(); - -Object.defineProperty(globalThis, GLOBAL_EXPECT, { - value: globalExpect, - writable: true, - configurable: true, -}); - -export { assert, should } from 'chai'; -export { chai, globalExpect as expect }; - -export type { - Assertion, - AsymmetricMatchersContaining, - DeeplyAllowMatchers, - ExpectStatic, - JestAssertion, - Matchers, -} from '@vitest/expect'; +export * from './expect.js'; diff --git a/packages/runtime/src/expect/matchers/toMatchImageSnapshot.ts b/packages/runtime/src/expect/matchers/toMatchImageSnapshot.ts new file mode 100644 index 0000000..c4cf046 --- /dev/null +++ b/packages/runtime/src/expect/matchers/toMatchImageSnapshot.ts @@ -0,0 +1,50 @@ +import { getClientInstance } from '../../client/store.js'; +import type { MatcherState } from '@vitest/expect'; +import { + type ImageSnapshotOptions, + generateTransferId, +} from '@react-native-harness/bridge'; +import { getHarnessContext } from '../../runner/index.js'; + +type ScreenshotResult = { + data: Uint8Array; + width: number; + height: number; +}; + +export async function toMatchImageSnapshot( + this: MatcherState, + received: ScreenshotResult, + options: ImageSnapshotOptions +): Promise<{ pass: boolean; message: () => string }> { + const client = getClientInstance(); + const context = getHarnessContext(); + + const transferId = generateTransferId(); + client.sendBinary(transferId, received.data); + + const screenshotFile = await client.rpc['device.screenshot.receive']( + { + type: 'binary', + transferId, + size: received.data.length, + mimeType: 'image/png', + }, + { + width: received.width, + height: received.height, + } + ); + + const result = await client.rpc['test.matchImageSnapshot']( + screenshotFile, + context.testFilePath, + options, + context.runner + ); + + return { + pass: result.pass, + message: () => result.message, + }; +} diff --git a/packages/runtime/src/globals.ts b/packages/runtime/src/globals.ts index 71d11a8..783b9f1 100644 --- a/packages/runtime/src/globals.ts +++ b/packages/runtime/src/globals.ts @@ -1,3 +1,5 @@ +import type { ImageSnapshotOptions } from '@react-native-harness/bridge'; + export type HarnessGlobal = { appRegistryComponentName: string; webSocketPort?: number; @@ -7,6 +9,16 @@ declare global { var RN_HARNESS: HarnessGlobal | undefined; } +declare module '@vitest/expect' { + interface Matchers { + /** + * Match the received screenshot against a stored snapshot. + * Creates a new snapshot if one doesn't exist. + */ + toMatchImageSnapshot(options: ImageSnapshotOptions): Promise; + } +} + export const getHarnessGlobal = (): HarnessGlobal => { const harnessGlobal = global.RN_HARNESS; diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index 1dc1c6e..70784c8 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -1,5 +1,5 @@ import './polyfills.js'; -import './globals.d.ts'; +import './globals.js'; export { UI as ReactNativeHarness } from './ui/index.js'; export * from './spy/index.js'; diff --git a/packages/runtime/src/render/TestComponentOverlay.tsx b/packages/runtime/src/render/TestComponentOverlay.tsx index 621378f..607928d 100644 --- a/packages/runtime/src/render/TestComponentOverlay.tsx +++ b/packages/runtime/src/render/TestComponentOverlay.tsx @@ -4,6 +4,21 @@ import { useRenderedElement } from '../ui/state.js'; import { store } from '../ui/state.js'; import { ErrorBoundary } from './ErrorBoundary.js'; +/** + * Waits for the native view hierarchy to be fully updated. + * Uses double requestAnimationFrame to ensure native has processed + * all view creation commands after React's commit phase. + */ +const waitForNativeViewHierarchy = (): Promise => { + return new Promise((resolve) => { + requestAnimationFrame(() => { + requestAnimationFrame(() => { + resolve(); + }); + }); + }); +}; + export const TestComponentOverlay = (): React.ReactElement | null => { const { element, key } = useRenderedElement(); @@ -12,8 +27,13 @@ export const TestComponentOverlay = (): React.ReactElement | null => { const callback = store.getState().onRenderCallback; if (callback) { - callback(); - store.getState().setOnRenderCallback(null); + // Wait for native view hierarchy to be fully updated before calling callback. + // useEffect fires after React commits, but native processes commands async. + // Double rAF ensures native has finished processing all view creation. + waitForNativeViewHierarchy().then(() => { + callback(); + store.getState().setOnRenderCallback(null); + }); } }, [element]); diff --git a/packages/runtime/src/render/index.ts b/packages/runtime/src/render/index.ts index 9129859..30eb84a 100644 --- a/packages/runtime/src/render/index.ts +++ b/packages/runtime/src/render/index.ts @@ -25,16 +25,18 @@ export const render = async ( store.getState().setOnRenderCallback(null); } - // Create a promise that resolves when the element is laid out - const layoutPromise = new Promise((resolve, reject) => { + // Create a promise that resolves when the element is rendered. + // We use onRenderCallback which fires in useEffect, guaranteeing that + // React has committed all children to the native view hierarchy. + const renderPromise = new Promise((resolve, reject) => { const timeoutId = setTimeout(() => { - store.getState().setOnLayoutCallback(null); + store.getState().setOnRenderCallback(null); reject( new Error(`Render timeout: Element did not mount within ${timeout}ms`) ); }, timeout); - store.getState().setOnLayoutCallback(() => { + store.getState().setOnRenderCallback(() => { clearTimeout(timeoutId); resolve(); }); @@ -44,8 +46,8 @@ export const render = async ( const wrappedElement = wrapElement(element, wrapper); store.getState().setRenderedElement(wrappedElement); - // Wait for layout - await layoutPromise; + // Wait for useEffect to fire, ensuring all children are committed + await renderPromise; const rerender = async (newElement: React.ReactElement): Promise => { if (store.getState().renderedElement === null) { diff --git a/packages/runtime/src/runner/context.ts b/packages/runtime/src/runner/context.ts new file mode 100644 index 0000000..ded33a0 --- /dev/null +++ b/packages/runtime/src/runner/context.ts @@ -0,0 +1,16 @@ +export type HarnessContext = { + testFilePath: string; + runner: string; +}; + +declare global { + var HARNESS_CONTEXT: HarnessContext; +} + +export const getHarnessContext = (): HarnessContext => { + return globalThis['HARNESS_CONTEXT']; +}; + +export const setHarnessContext = (context: HarnessContext): void => { + globalThis['HARNESS_CONTEXT'] = context; +}; diff --git a/packages/runtime/src/runner/factory.ts b/packages/runtime/src/runner/factory.ts index c78d60c..49c3be1 100644 --- a/packages/runtime/src/runner/factory.ts +++ b/packages/runtime/src/runner/factory.ts @@ -2,13 +2,19 @@ import type { TestRunnerEvents } from '@react-native-harness/bridge'; import { getEmitter } from '../utils/emitter.js'; import { runSuite } from './runSuite.js'; import { TestRunner } from './types.js'; +import { setHarnessContext } from './context.js'; export const getTestRunner = (): TestRunner => { const events = getEmitter(); return { events, - run: async (testSuite, testFilePath) => { + run: async ({ testSuite, testFilePath, runner }) => { + setHarnessContext({ + testFilePath, + runner, + }); + const result = await runSuite(testSuite, { events, testFilePath, diff --git a/packages/runtime/src/runner/index.ts b/packages/runtime/src/runner/index.ts index d2e8802..0801835 100644 --- a/packages/runtime/src/runner/index.ts +++ b/packages/runtime/src/runner/index.ts @@ -5,3 +5,8 @@ export type { } from './types.js'; export { TestExecutionError } from './errors.js'; export { getTestRunner } from './factory.js'; +export { + getHarnessContext, + setHarnessContext, + type HarnessContext, +} from './context.js'; diff --git a/packages/runtime/src/runner/runSuite.ts b/packages/runtime/src/runner/runSuite.ts index 9c1512d..c189d37 100644 --- a/packages/runtime/src/runner/runSuite.ts +++ b/packages/runtime/src/runner/runSuite.ts @@ -8,6 +8,10 @@ import { runHooks } from './hooks.js'; import { getTestExecutionError } from './errors.js'; import { TestRunnerContext } from './types.js'; +declare global { + var HARNESS_TEST_PATH: string; +} + const runTest = async ( test: TestCase, suite: TestSuite, diff --git a/packages/runtime/src/runner/types.ts b/packages/runtime/src/runner/types.ts index 4e31918..2ec75fb 100644 --- a/packages/runtime/src/runner/types.ts +++ b/packages/runtime/src/runner/types.ts @@ -12,8 +12,14 @@ export type TestRunnerContext = { testFilePath: string; }; +export type RunTestsOptions = { + testSuite: TestSuite; + testFilePath: string; + runner: string; +}; + export type TestRunner = { events: TestRunnerEventsEmitter; - run: (testSuite: TestSuite, testFilePath: string) => Promise; + run: (options: RunTestsOptions) => Promise; dispose: () => void; }; diff --git a/packages/runtime/src/ui/ReadyScreen.tsx b/packages/runtime/src/ui/ReadyScreen.tsx index 4b20dcd..29dd8ab 100644 --- a/packages/runtime/src/ui/ReadyScreen.tsx +++ b/packages/runtime/src/ui/ReadyScreen.tsx @@ -16,6 +16,7 @@ export const ReadyScreen = () => { return ( +