From c7a934fc0df49aecd1c6ef111cb169b5d627afbd Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Thu, 15 Jan 2026 15:19:23 +0100 Subject: [PATCH 01/13] feat: implement UI queries and actions --- apps/playground/ios/Podfile.lock | 32 ++ apps/playground/jest.config.js | 2 +- apps/playground/package.json | 2 + apps/playground/rn-harness.config.mjs | 6 +- .../__image_snapshots__/android/full-ui.png | Bin 0 -> 19055 bytes .../android/orange-square-element-only.png | Bin 0 -> 3129 bytes .../android/yellow-square-custom-options.png | Bin 0 -> 21351 bytes .../ui/__image_snapshots__/ios/full-ui.png | Bin 0 -> 77344 bytes .../ios/orange-square-element-only.png | Bin 0 -> 5871 bytes .../ios/yellow-square-custom-options.png | Bin 0 -> 77919 bytes .../src/__tests__/ui/actions.harness.tsx | 33 ++ .../src/__tests__/ui/queries.harness.tsx | 34 ++ .../src/__tests__/ui/screenshot.harness.tsx | 84 +++ .../src/__tests__/ui/type.harness.tsx | 124 +++++ apps/playground/tsconfig.app.json | 6 + apps/playground/tsconfig.json | 6 + packages/bridge/package.json | 6 + packages/bridge/src/binary-transfer.ts | 79 +++ packages/bridge/src/client.ts | 11 +- packages/bridge/src/image-snapshot.ts | 170 ++++++ packages/bridge/src/index.ts | 1 + packages/bridge/src/server.ts | 98 +++- packages/bridge/src/shared.ts | 94 +++- packages/bridge/tsconfig.json | 3 + packages/bridge/tsconfig.lib.json | 3 + packages/config/src/types.ts | 14 +- packages/jest/src/harness.ts | 25 +- packages/jest/src/index.ts | 2 +- packages/jest/src/run.ts | 1 + packages/platform-android/package.json | 3 +- packages/platform-android/src/adb.ts | 47 ++ packages/platform-ios/src/instance.ts | 9 - packages/platform-ios/src/utils.ts | 1 - packages/platform-ios/src/xcrun/simctl.ts | 8 + packages/platform-ios/tsconfig.tsbuildinfo | 1 + packages/platforms/src/errors.ts | 14 + packages/platforms/src/index.ts | 6 +- packages/runtime/package.json | 18 + packages/runtime/src/client/factory.ts | 11 +- packages/runtime/src/client/store.ts | 16 + packages/runtime/src/expect/expect.ts | 127 +++++ packages/runtime/src/expect/index.ts | 124 +---- .../expect/matchers/toMatchImageSnapshot.ts | 56 ++ packages/runtime/src/runner/context.ts | 16 + packages/runtime/src/runner/factory.ts | 8 +- packages/runtime/src/runner/index.ts | 5 + packages/runtime/src/runner/runSuite.ts | 4 + packages/runtime/src/runner/types.ts | 8 +- packages/runtime/src/ui/ReadyScreen.tsx | 1 + packages/ui/HarnessUI.podspec | 20 + packages/ui/android/build.gradle | 77 +++ packages/ui/android/gradle.properties | 5 + .../java/com/harnessui/HarnessUIModule.kt | 53 ++ .../java/com/harnessui/HarnessUIPackage.kt | 33 ++ .../main/java/com/harnessui/UIHelperImpl.kt | 335 +++++++++++ .../java/com/harnessui/ViewQueryHelper.kt | 135 +++++ packages/ui/eslint.config.mjs | 19 + packages/ui/ios/HarnessUI.h | 5 + packages/ui/ios/HarnessUI.mm | 526 ++++++++++++++++++ packages/ui/ios/ViewQueryHelper.h | 64 +++ packages/ui/ios/ViewQueryHelper.mm | 187 +++++++ packages/ui/package.json | 66 +++ packages/ui/react-native.config.cjs | 12 + packages/ui/src/NativeHarnessUI.ts | 66 +++ packages/ui/src/index.ts | 18 + packages/ui/src/screen.ts | 175 ++++++ packages/ui/src/userEvent.ts | 115 ++++ packages/ui/tsconfig.json | 10 + packages/ui/tsconfig.lib.json | 18 + packages/ui/tsconfig.tsbuildinfo | 1 + pnpm-lock.yaml | 164 ++++-- pnpm-workspace.yaml | 3 + tsconfig.json | 3 + website/src/docs/api/rendering-components.md | 3 +- .../docs/getting-started/configuration.mdx | 14 +- website/src/docs/guides/_meta.json | 17 + website/src/docs/guides/index.md | 1 + website/src/docs/guides/ui-testing.md | 231 ++++++++ 78 files changed, 3480 insertions(+), 215 deletions(-) create mode 100644 apps/playground/src/__tests__/ui/__image_snapshots__/android/full-ui.png create mode 100644 apps/playground/src/__tests__/ui/__image_snapshots__/android/orange-square-element-only.png create mode 100644 apps/playground/src/__tests__/ui/__image_snapshots__/android/yellow-square-custom-options.png create mode 100644 apps/playground/src/__tests__/ui/__image_snapshots__/ios/full-ui.png create mode 100644 apps/playground/src/__tests__/ui/__image_snapshots__/ios/orange-square-element-only.png create mode 100644 apps/playground/src/__tests__/ui/__image_snapshots__/ios/yellow-square-custom-options.png create mode 100644 apps/playground/src/__tests__/ui/actions.harness.tsx create mode 100644 apps/playground/src/__tests__/ui/queries.harness.tsx create mode 100644 apps/playground/src/__tests__/ui/screenshot.harness.tsx create mode 100644 apps/playground/src/__tests__/ui/type.harness.tsx create mode 100644 packages/bridge/src/binary-transfer.ts create mode 100644 packages/bridge/src/image-snapshot.ts create mode 100644 packages/platform-ios/tsconfig.tsbuildinfo create mode 100644 packages/runtime/src/client/store.ts create mode 100644 packages/runtime/src/expect/expect.ts create mode 100644 packages/runtime/src/expect/matchers/toMatchImageSnapshot.ts create mode 100644 packages/runtime/src/runner/context.ts create mode 100644 packages/ui/HarnessUI.podspec create mode 100644 packages/ui/android/build.gradle create mode 100644 packages/ui/android/gradle.properties create mode 100644 packages/ui/android/src/main/java/com/harnessui/HarnessUIModule.kt create mode 100644 packages/ui/android/src/main/java/com/harnessui/HarnessUIPackage.kt create mode 100644 packages/ui/android/src/main/java/com/harnessui/UIHelperImpl.kt create mode 100644 packages/ui/android/src/main/java/com/harnessui/ViewQueryHelper.kt create mode 100644 packages/ui/eslint.config.mjs create mode 100644 packages/ui/ios/HarnessUI.h create mode 100644 packages/ui/ios/HarnessUI.mm create mode 100644 packages/ui/ios/ViewQueryHelper.h create mode 100644 packages/ui/ios/ViewQueryHelper.mm create mode 100644 packages/ui/package.json create mode 100644 packages/ui/react-native.config.cjs create mode 100644 packages/ui/src/NativeHarnessUI.ts create mode 100644 packages/ui/src/index.ts create mode 100644 packages/ui/src/screen.ts create mode 100644 packages/ui/src/userEvent.ts create mode 100644 packages/ui/tsconfig.json create mode 100644 packages/ui/tsconfig.lib.json create mode 100644 packages/ui/tsconfig.tsbuildinfo create mode 100644 website/src/docs/guides/_meta.json create mode 100644 website/src/docs/guides/ui-testing.md 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/rn-harness.config.mjs b/apps/playground/rn-harness.config.mjs index 0c49b64..28ea428 100644 --- a/apps/playground/rn-harness.config.mjs +++ b/apps/playground/rn-harness.config.mjs @@ -20,7 +20,7 @@ const config = { runners: [ androidPlatform({ name: 'android', - device: androidEmulator('Pixel_8_API_35', { + device: androidEmulator('Pixel_8_API_33', { apiLevel: 35, profile: 'pixel_6', diskSize: '1G', @@ -40,7 +40,7 @@ const config = { }), applePlatform({ name: 'ios', - device: appleSimulator('iPhone 16 Pro', '18.6'), + device: appleSimulator('iPhone 16 Pro', '26.0'), bundleId: 'com.harnessplayground', }), vegaPlatform({ @@ -49,7 +49,7 @@ const config = { bundleId: 'com.playground', }), ], - defaultRunner: 'android', + defaultRunner: 'ios', bridgeTimeout: 120000, webSocketPort: 3002, diff --git a/apps/playground/src/__tests__/ui/__image_snapshots__/android/full-ui.png b/apps/playground/src/__tests__/ui/__image_snapshots__/android/full-ui.png new file mode 100644 index 0000000000000000000000000000000000000000..4b773f22fce17e3d61f95d9b005ea2d83b6a8abe GIT binary patch literal 19055 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 IZ{mOY4@ZuU$^ZZW literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..0fe0b77aa46e54e4375479052f7546eade340002 GIT binary patch literal 3129 zcmeHJ=Tj4i5)Y@N839EEL5fI?AySSSLzQwNNZx%eO*)36?CqjrIIgFX3bvi^GdwF8Lj zpdePRs(A7vSM}#W3-889$3qB4{odS4vxN4JWlpGO|GLm=-oXI0I5L_Mo3NaBDrG^T;gU{-k| zME4xcaDCp))a8`klLmpIO4Nkt_s5uhvZc{;ZMR^ok76%=zr%DyI9+Tnyi&AFMJL3pOw6@d z#CYZDd3B+gp#8v)i_Clw%FK5mgE?vBmi|Xl)lKH-2}Hp%NN`XZ{xP8-EaD2mqpHtX z0e{C)Nov~^4)gW6BO$X&I#95UHG9$}sY(v3*INXWxgZ@a%Byz0-Lr@0J@`1o+|zlc zJMb)v8pVx*j?Yl+R3T4cGfI+wU6QXlniZe*YHGwVM}0}fj;<8kF6yUAk002ljU^4H{o#C_?wf*qG%mfHac`|W`URv56d0vwC&kAn$6L#U zxqZoL-ufmF9gG-sh{?>!svw^=zhYZ%gwk=B0L%t(fJKL<5 zI22Y!v^Vp7$STU`0goRxK1T)=+|mY3B>Rjv0tTuCc|)0r&Je z7h47xpEm4y)K@8^fpL4ikDRG?~eG6)&~wA8GMGMCv6x1MYK8OyLG`-MZ5KDx*b8ZGF6yP>hKi#(r0i~K5C ze_ZaPeRs_iPLag)>Hn^YNT=mFlf36_JC6nbcezP6IghA8*?CtUw&GSewSTl~W!|rx zAMn3WD2ufDd=HRMr`1V*alvtb#WhVh%lWWRPsA#BUdP*(J^r85QJ9?y4x^+ov`#%>ApBSy_bglIJmiqV&E>1 zn@E|gPinchXAe=iz8*gNV|hgiP^z#~))2F_GmTV$M@kV$5o zc6L>BlLfDnBN1#CYp7!iG~H(2OaNoX15nwRL89<2`n@g_AG{VWXaOnKLG z5|Zd*N!2<&m8iXtzl{SrOJ}wzr&+RJst^r&Nn=^b?b_k;=LBd2rFND}#M7`9laE)A z--g2{v{cer6U&*0DHq)`4x#CPDCwqcaw>+%q~6smCFXn?gi8)$Nw0 z;QlvmR_kP)1UG=6J$0G}Yy3b_S94PJXY|=9cC9~+u!II&(!n6=y4%ZHU z_uEVuB9v^1<{5?ccz?Q@#o)N5tmh{nnUP+9>qfh>yQFK2&em481jc+X;|&6~211@| z2qvl~_;H{2R2;|mLvnlO2)lA85Hi6JcvL_A8_?5YCsZQG&oQ3|T_5qtg_XC_LTNLp zh7r5p_$jymC}%SF;i`@+42KBwuo^VLuIYY>RN1M?h2GBp3QhxIC!S;MG%SCQ$GS@t$T7htWK*ZUpM+U`^swR$iUm7g2 zkjPZ7`T0`Q5S_+YAe3B82vmRll0D4c3|1N)@`lGI_URmvyn68epZ&i??;oJOy9$YF X{wFNvRYQ*GKLBBEZ&hdU+vEQMlh{-w literal 0 HcmV?d00001 diff --git a/apps/playground/src/__tests__/ui/__image_snapshots__/android/yellow-square-custom-options.png b/apps/playground/src/__tests__/ui/__image_snapshots__/android/yellow-square-custom-options.png new file mode 100644 index 0000000000000000000000000000000000000000..c3edb04a68da35f1edccb19b4edf267dbf49e5ac GIT binary patch literal 21351 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 literal 0 HcmV?d00001 diff --git a/apps/playground/src/__tests__/ui/__image_snapshots__/ios/full-ui.png b/apps/playground/src/__tests__/ui/__image_snapshots__/ios/full-ui.png new file mode 100644 index 0000000000000000000000000000000000000000..2c472a9e67e00057541de3bc7a7adf4869dd0951 GIT binary patch literal 77344 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 Qdn{Y>!{VGp8~^(M0J3??*#H0l literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..6d7bf08ba86e96c7753198c027f84798bf87c9f6 GIT binary patch literal 5871 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 C@m+=h literal 0 HcmV?d00001 diff --git a/apps/playground/src/__tests__/ui/__image_snapshots__/ios/yellow-square-custom-options.png b/apps/playground/src/__tests__/ui/__image_snapshots__/ios/yellow-square-custom-options.png new file mode 100644 index 0000000000000000000000000000000000000000..4d5d725f8dd98388a2a6c4b190c3b563e49e56dc GIT binary patch literal 77919 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 { + 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..3cb4d18 --- /dev/null +++ b/apps/playground/src/__tests__/ui/screenshot.harness.tsx @@ -0,0 +1,84 @@ +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 match unbounded image snapshot', async () => { + await render( + + + Hello, world! + + + ); + const screenshot = await screen.screenshot(); + await expect(screenshot).toMatchImageSnapshot({ name: 'full-ui' }); + }); + + test('should match bounded image snapshot', 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 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..c238d55 100644 --- a/apps/playground/tsconfig.app.json +++ b/apps/playground/tsconfig.app.json @@ -45,6 +45,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/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/platform-android/package.json b/packages/platform-android/package.json index 5e96504..8f55cd5 100644 --- a/packages/platform-android/package.json +++ b/packages/platform-android/package.json @@ -20,7 +20,8 @@ "@react-native-harness/tools": "workspace:*", "@react-native-harness/config": "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 48d56c2..17a0e88 100644 --- a/packages/platform-android/src/adb.ts +++ b/packages/platform-android/src/adb.ts @@ -122,3 +122,50 @@ export const isAppRunning = async ( ]); return stdout.trim() !== ''; }; + +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-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/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..06af83b --- /dev/null +++ b/packages/runtime/src/expect/matchers/toMatchImageSnapshot.ts @@ -0,0 +1,56 @@ +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'; + +declare module '@vitest/expect' { + interface Matchers { + toMatchImageSnapshot(options: ImageSnapshotOptions): Promise; + } +} + +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/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 ( +