├── example ├── .watchmanconfig ├── _node-version ├── .owl │ ├── .gitignore │ └── baseline │ │ ├── ios │ │ ├── initial.png │ │ ├── long-press.png │ │ ├── scroll-to.png │ │ ├── test-input.png │ │ ├── after-reload.png │ │ ├── entered-text.png │ │ ├── scroll-to-end.png │ │ └── custom-threshold.png │ │ └── android │ │ ├── initial.png │ │ ├── scroll-to.png │ │ ├── long-press.png │ │ ├── test-input.png │ │ ├── after-reload.png │ │ ├── entered-text.png │ │ ├── scroll-to-end.png │ │ └── custom-threshold.png ├── ios │ ├── .xcode.env │ ├── OwlDemo │ │ ├── Images.xcassets │ │ │ ├── Contents.json │ │ │ └── AppIcon.appiconset │ │ │ │ └── Contents.json │ │ ├── AppDelegate.h │ │ ├── main.m │ │ ├── AppDelegate.mm │ │ ├── Info.plist │ │ └── LaunchScreen.storyboard │ ├── OwlDemo.xcworkspace │ │ └── contents.xcworkspacedata │ ├── _xcode.env │ ├── OwlDemoTests │ │ ├── Info.plist │ │ └── OwlDemoTests.m │ ├── Podfile │ └── OwlDemo.xcodeproj │ │ └── xcshareddata │ │ └── xcschemes │ │ └── OwlDemo.xcscheme ├── app.json ├── .bundle │ └── config ├── tsconfig.json ├── .eslintrc.js ├── babel.config.js ├── assets │ ├── logo@1x.png │ ├── logo@2x.png │ └── logo@3x.png ├── android │ ├── app │ │ ├── src │ │ │ ├── main │ │ │ │ ├── res │ │ │ │ │ ├── values │ │ │ │ │ │ ├── strings.xml │ │ │ │ │ │ └── styles.xml │ │ │ │ │ ├── mipmap-hdpi │ │ │ │ │ │ ├── ic_launcher.png │ │ │ │ │ │ └── ic_launcher_round.png │ │ │ │ │ ├── mipmap-mdpi │ │ │ │ │ │ ├── ic_launcher.png │ │ │ │ │ │ └── ic_launcher_round.png │ │ │ │ │ ├── mipmap-xhdpi │ │ │ │ │ │ ├── ic_launcher.png │ │ │ │ │ │ └── ic_launcher_round.png │ │ │ │ │ ├── mipmap-xxhdpi │ │ │ │ │ │ ├── ic_launcher.png │ │ │ │ │ │ └── ic_launcher_round.png │ │ │ │ │ ├── mipmap-xxxhdpi │ │ │ │ │ │ ├── ic_launcher.png │ │ │ │ │ │ └── ic_launcher_round.png │ │ │ │ │ └── drawable │ │ │ │ │ │ └── rn_edit_text_material.xml │ │ │ │ ├── AndroidManifest.xml │ │ │ │ └── java │ │ │ │ │ └── com │ │ │ │ │ └── owldemo │ │ │ │ │ ├── MainActivity.java │ │ │ │ │ └── MainApplication.java │ │ │ ├── debug │ │ │ │ ├── AndroidManifest.xml │ │ │ │ └── java │ │ │ │ │ └── com │ │ │ │ │ └── owldemo │ │ │ │ │ └── ReactNativeFlipper.java │ │ │ └── release │ │ │ │ └── java │ │ │ │ └── com │ │ │ │ └── owldemo │ │ │ │ └── ReactNativeFlipper.java │ │ ├── debug.keystore │ │ └── proguard-rules.pro │ ├── gradle │ │ └── wrapper │ │ │ ├── gradle-wrapper.jar │ │ │ └── gradle-wrapper.properties │ ├── settings.gradle │ ├── build.gradle │ ├── gradle.properties │ └── gradlew.bat ├── .prettierrc.js ├── Gemfile ├── index.js ├── __tests__ │ ├── App-test.tsx │ └── App.owl.tsx ├── owl.config.json ├── metro.config.js ├── .gitignore ├── package.json └── README.md ├── website ├── static │ ├── .nojekyll │ └── images │ │ ├── favicon.ico │ │ ├── social.png │ │ ├── homepage │ │ ├── diff.png │ │ ├── mockup.png │ │ ├── report.png │ │ └── mockup-bg.png │ │ ├── nearform-logo-white.svg │ │ └── logo-eyes.svg ├── tsconfig.json ├── babel.config.js ├── .gitignore ├── src │ ├── components │ │ ├── HomepageFeatures │ │ │ ├── styles.module.css │ │ │ └── index.tsx │ │ └── Hero │ │ │ ├── styles.module.css │ │ │ └── index.tsx │ ├── pages │ │ └── index.tsx │ └── css │ │ └── custom.css ├── README.md ├── sidebars.js ├── package.json └── docusaurus.config.ts ├── .prettierignore ├── lib ├── constants.ts ├── client │ ├── index.ts │ ├── index.app.ts │ ├── constants.ts │ ├── trackedElements.test.ts │ ├── trackedElements.ts │ ├── websocket.ts │ ├── websocket.test.ts │ ├── client.test.ts │ └── handleAction.ts ├── index.ts ├── utils │ ├── file-exists.ts │ ├── wait-for.ts │ ├── file-exists.test.ts │ ├── adb.ts │ └── adb.test.ts ├── websocketTypes.ts ├── logger.ts ├── websocket.ts ├── cli │ ├── index.ts │ ├── build.ts │ └── config.ts ├── logger.test.ts ├── websocket.test.ts ├── report.test.ts ├── matchers.ts ├── screenshot.ts ├── types.ts ├── report.ts ├── actions.ts └── matchers.test.ts ├── docs ├── api │ ├── _category_.json │ └── matchers.mdx ├── cli │ ├── _category_.json │ ├── building-the-app.mdx │ └── testing-the-app.mdx ├── ci │ ├── _category_.json │ └── github-actions.md └── introduction │ ├── _category_.json │ ├── work-in-progress.mdx │ ├── config-file.mdx │ └── getting-started.mdx ├── .prettierrc.js ├── ExpoExample ├── .owl │ ├── .gitignore │ └── baseline │ │ ├── ios │ │ ├── initial.png │ │ ├── scroll-to.png │ │ ├── long-press.png │ │ ├── test-input.png │ │ ├── after-reload.png │ │ ├── entered-text.png │ │ ├── scroll-to-end.png │ │ └── custom-threshold.png │ │ └── android │ │ ├── initial.png │ │ ├── long-press.png │ │ ├── scroll-to.png │ │ ├── test-input.png │ │ ├── after-reload.png │ │ ├── entered-text.png │ │ ├── scroll-to-end.png │ │ └── custom-threshold.png ├── tsconfig.json ├── assets │ ├── icon.png │ ├── favicon.png │ ├── logo@1x.png │ ├── logo@2x.png │ ├── logo@3x.png │ ├── splash.png │ └── adaptive-icon.png ├── babel.config.js ├── .gitignore ├── index.js ├── owl.config.json ├── app.json ├── package.json ├── metro.config.js └── __tests__ │ └── App.owl.tsx ├── native └── android │ ├── .gitignore │ ├── src │ └── main │ │ ├── AndroidManifest.xml │ │ ├── res │ │ └── xml │ │ │ └── network_security_config.xml │ │ ├── AndroidManifest.owl.xml │ │ └── java │ │ └── com │ │ └── formidable │ │ └── reactnativeowl │ │ ├── ReactNativeOwlModule.java │ │ └── ReactNativeOwlPackage.java │ ├── templates │ ├── ReactNativeOwlModule.java │ └── ReactNativeOwlModuleOwl.java │ └── build.gradle ├── react-native.config.js ├── .changeset └── config.json ├── scripts └── websocket-server.js ├── .github ├── workflows │ ├── run-tests.yml │ ├── release.yml │ ├── demo-app.yml │ └── demo-expo-app.yml ├── PULL_REQUEST_TEMPLATE.md ├── CONTRIBUTING.MD └── CODE_OF_CONDUCT.md ├── LICENSE ├── .gitignore ├── CHANGELOG.md ├── tsconfig.json ├── package.json └── README.md /example/.watchmanconfig: -------------------------------------------------------------------------------- 1 | {} -------------------------------------------------------------------------------- /example/_node-version: -------------------------------------------------------------------------------- 1 | 18 2 | -------------------------------------------------------------------------------- /website/static/.nojekyll: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | tsconfig.json 2 | -------------------------------------------------------------------------------- /example/.owl/.gitignore: -------------------------------------------------------------------------------- 1 | diff/ 2 | latest/ 3 | report/ 4 | -------------------------------------------------------------------------------- /lib/constants.ts: -------------------------------------------------------------------------------- 1 | export const WEBSOCKET_PORT = 8123; 2 | -------------------------------------------------------------------------------- /example/ios/.xcode.env: -------------------------------------------------------------------------------- 1 | export NODE_BINARY=$(command -v node) 2 | -------------------------------------------------------------------------------- /docs/api/_category_.json: -------------------------------------------------------------------------------- 1 | { 2 | "label": "API", 3 | "position": 3 4 | } 5 | -------------------------------------------------------------------------------- /docs/cli/_category_.json: -------------------------------------------------------------------------------- 1 | { 2 | "label": "CLI", 3 | "position": 2 4 | } 5 | -------------------------------------------------------------------------------- /example/app.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "OwlDemo", 3 | "displayName": "OwlDemo" 4 | } -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | tabWidth: 2, 3 | singleQuote: true, 4 | }; 5 | -------------------------------------------------------------------------------- /docs/ci/_category_.json: -------------------------------------------------------------------------------- 1 | { 2 | "label": "Running on CI", 3 | "position": 4 4 | } 5 | -------------------------------------------------------------------------------- /example/.bundle/config: -------------------------------------------------------------------------------- 1 | BUNDLE_PATH: "vendor/bundle" 2 | BUNDLE_FORCE_RUBY_PLATFORM: 1 3 | -------------------------------------------------------------------------------- /example/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/react-native/tsconfig.json" 3 | } 4 | -------------------------------------------------------------------------------- /ExpoExample/.owl/.gitignore: -------------------------------------------------------------------------------- 1 | # generated by react-native-owl 2 | diff/ 3 | latest/ 4 | report/ 5 | -------------------------------------------------------------------------------- /docs/introduction/_category_.json: -------------------------------------------------------------------------------- 1 | { 2 | "label": "Introduction", 3 | "position": 1 4 | } 5 | -------------------------------------------------------------------------------- /lib/client/index.ts: -------------------------------------------------------------------------------- 1 | import { initClient } from './client'; 2 | 3 | export { initClient }; 4 | -------------------------------------------------------------------------------- /website/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@docusaurus/tsconfig", 3 | "include": ["src/"] 4 | } 5 | -------------------------------------------------------------------------------- /ExpoExample/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": {}, 3 | "extends": "expo/tsconfig.base" 4 | } 5 | -------------------------------------------------------------------------------- /example/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | extends: '@react-native-community', 4 | }; 5 | -------------------------------------------------------------------------------- /example/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: ['module:metro-react-native-babel-preset'], 3 | }; 4 | -------------------------------------------------------------------------------- /ExpoExample/assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FormidableLabs/react-native-owl/HEAD/ExpoExample/assets/icon.png -------------------------------------------------------------------------------- /example/assets/logo@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FormidableLabs/react-native-owl/HEAD/example/assets/logo@1x.png -------------------------------------------------------------------------------- /example/assets/logo@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FormidableLabs/react-native-owl/HEAD/example/assets/logo@2x.png -------------------------------------------------------------------------------- /example/assets/logo@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FormidableLabs/react-native-owl/HEAD/example/assets/logo@3x.png -------------------------------------------------------------------------------- /ExpoExample/assets/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FormidableLabs/react-native-owl/HEAD/ExpoExample/assets/favicon.png -------------------------------------------------------------------------------- /ExpoExample/assets/logo@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FormidableLabs/react-native-owl/HEAD/ExpoExample/assets/logo@1x.png -------------------------------------------------------------------------------- /ExpoExample/assets/logo@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FormidableLabs/react-native-owl/HEAD/ExpoExample/assets/logo@2x.png -------------------------------------------------------------------------------- /ExpoExample/assets/logo@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FormidableLabs/react-native-owl/HEAD/ExpoExample/assets/logo@3x.png -------------------------------------------------------------------------------- /ExpoExample/assets/splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FormidableLabs/react-native-owl/HEAD/ExpoExample/assets/splash.png -------------------------------------------------------------------------------- /website/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [require.resolve('@docusaurus/core/lib/babel/preset')], 3 | }; 4 | -------------------------------------------------------------------------------- /example/android/app/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | OwlDemo 3 | 4 | -------------------------------------------------------------------------------- /native/android/.gitignore: -------------------------------------------------------------------------------- 1 | # Android/IntelliJ 2 | # 3 | build/ 4 | .idea 5 | .gradle 6 | local.properties 7 | *.iml 8 | *.hprof 9 | -------------------------------------------------------------------------------- /website/static/images/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FormidableLabs/react-native-owl/HEAD/website/static/images/favicon.ico -------------------------------------------------------------------------------- /website/static/images/social.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FormidableLabs/react-native-owl/HEAD/website/static/images/social.png -------------------------------------------------------------------------------- /example/android/app/debug.keystore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FormidableLabs/react-native-owl/HEAD/example/android/app/debug.keystore -------------------------------------------------------------------------------- /example/ios/OwlDemo/Images.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /ExpoExample/assets/adaptive-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FormidableLabs/react-native-owl/HEAD/ExpoExample/assets/adaptive-icon.png -------------------------------------------------------------------------------- /example/.owl/baseline/ios/initial.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FormidableLabs/react-native-owl/HEAD/example/.owl/baseline/ios/initial.png -------------------------------------------------------------------------------- /example/.owl/baseline/ios/long-press.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FormidableLabs/react-native-owl/HEAD/example/.owl/baseline/ios/long-press.png -------------------------------------------------------------------------------- /example/.owl/baseline/ios/scroll-to.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FormidableLabs/react-native-owl/HEAD/example/.owl/baseline/ios/scroll-to.png -------------------------------------------------------------------------------- /example/.owl/baseline/ios/test-input.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FormidableLabs/react-native-owl/HEAD/example/.owl/baseline/ios/test-input.png -------------------------------------------------------------------------------- /website/static/images/homepage/diff.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FormidableLabs/react-native-owl/HEAD/website/static/images/homepage/diff.png -------------------------------------------------------------------------------- /ExpoExample/.owl/baseline/ios/initial.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FormidableLabs/react-native-owl/HEAD/ExpoExample/.owl/baseline/ios/initial.png -------------------------------------------------------------------------------- /ExpoExample/.owl/baseline/ios/scroll-to.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FormidableLabs/react-native-owl/HEAD/ExpoExample/.owl/baseline/ios/scroll-to.png -------------------------------------------------------------------------------- /example/.owl/baseline/android/initial.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FormidableLabs/react-native-owl/HEAD/example/.owl/baseline/android/initial.png -------------------------------------------------------------------------------- /example/.owl/baseline/android/scroll-to.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FormidableLabs/react-native-owl/HEAD/example/.owl/baseline/android/scroll-to.png -------------------------------------------------------------------------------- /example/.owl/baseline/ios/after-reload.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FormidableLabs/react-native-owl/HEAD/example/.owl/baseline/ios/after-reload.png -------------------------------------------------------------------------------- /example/.owl/baseline/ios/entered-text.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FormidableLabs/react-native-owl/HEAD/example/.owl/baseline/ios/entered-text.png -------------------------------------------------------------------------------- /example/.owl/baseline/ios/scroll-to-end.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FormidableLabs/react-native-owl/HEAD/example/.owl/baseline/ios/scroll-to-end.png -------------------------------------------------------------------------------- /website/static/images/homepage/mockup.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FormidableLabs/react-native-owl/HEAD/website/static/images/homepage/mockup.png -------------------------------------------------------------------------------- /website/static/images/homepage/report.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FormidableLabs/react-native-owl/HEAD/website/static/images/homepage/report.png -------------------------------------------------------------------------------- /ExpoExample/.owl/baseline/android/initial.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FormidableLabs/react-native-owl/HEAD/ExpoExample/.owl/baseline/android/initial.png -------------------------------------------------------------------------------- /ExpoExample/.owl/baseline/ios/long-press.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FormidableLabs/react-native-owl/HEAD/ExpoExample/.owl/baseline/ios/long-press.png -------------------------------------------------------------------------------- /ExpoExample/.owl/baseline/ios/test-input.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FormidableLabs/react-native-owl/HEAD/ExpoExample/.owl/baseline/ios/test-input.png -------------------------------------------------------------------------------- /ExpoExample/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = function(api) { 2 | api.cache(true); 3 | return { 4 | presets: ['babel-preset-expo'], 5 | }; 6 | }; 7 | -------------------------------------------------------------------------------- /example/.owl/baseline/android/long-press.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FormidableLabs/react-native-owl/HEAD/example/.owl/baseline/android/long-press.png -------------------------------------------------------------------------------- /example/.owl/baseline/android/test-input.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FormidableLabs/react-native-owl/HEAD/example/.owl/baseline/android/test-input.png -------------------------------------------------------------------------------- /example/ios/OwlDemo/AppDelegate.h: -------------------------------------------------------------------------------- 1 | #import 2 | #import 3 | 4 | @interface AppDelegate : RCTAppDelegate 5 | 6 | @end 7 | -------------------------------------------------------------------------------- /website/static/images/homepage/mockup-bg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FormidableLabs/react-native-owl/HEAD/website/static/images/homepage/mockup-bg.png -------------------------------------------------------------------------------- /ExpoExample/.owl/baseline/android/long-press.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FormidableLabs/react-native-owl/HEAD/ExpoExample/.owl/baseline/android/long-press.png -------------------------------------------------------------------------------- /ExpoExample/.owl/baseline/android/scroll-to.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FormidableLabs/react-native-owl/HEAD/ExpoExample/.owl/baseline/android/scroll-to.png -------------------------------------------------------------------------------- /ExpoExample/.owl/baseline/android/test-input.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FormidableLabs/react-native-owl/HEAD/ExpoExample/.owl/baseline/android/test-input.png -------------------------------------------------------------------------------- /ExpoExample/.owl/baseline/ios/after-reload.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FormidableLabs/react-native-owl/HEAD/ExpoExample/.owl/baseline/ios/after-reload.png -------------------------------------------------------------------------------- /ExpoExample/.owl/baseline/ios/entered-text.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FormidableLabs/react-native-owl/HEAD/ExpoExample/.owl/baseline/ios/entered-text.png -------------------------------------------------------------------------------- /ExpoExample/.owl/baseline/ios/scroll-to-end.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FormidableLabs/react-native-owl/HEAD/ExpoExample/.owl/baseline/ios/scroll-to-end.png -------------------------------------------------------------------------------- /example/.owl/baseline/android/after-reload.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FormidableLabs/react-native-owl/HEAD/example/.owl/baseline/android/after-reload.png -------------------------------------------------------------------------------- /example/.owl/baseline/android/entered-text.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FormidableLabs/react-native-owl/HEAD/example/.owl/baseline/android/entered-text.png -------------------------------------------------------------------------------- /example/.owl/baseline/android/scroll-to-end.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FormidableLabs/react-native-owl/HEAD/example/.owl/baseline/android/scroll-to-end.png -------------------------------------------------------------------------------- /example/.owl/baseline/ios/custom-threshold.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FormidableLabs/react-native-owl/HEAD/example/.owl/baseline/ios/custom-threshold.png -------------------------------------------------------------------------------- /ExpoExample/.owl/baseline/android/after-reload.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FormidableLabs/react-native-owl/HEAD/ExpoExample/.owl/baseline/android/after-reload.png -------------------------------------------------------------------------------- /ExpoExample/.owl/baseline/android/entered-text.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FormidableLabs/react-native-owl/HEAD/ExpoExample/.owl/baseline/android/entered-text.png -------------------------------------------------------------------------------- /ExpoExample/.owl/baseline/ios/custom-threshold.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FormidableLabs/react-native-owl/HEAD/ExpoExample/.owl/baseline/ios/custom-threshold.png -------------------------------------------------------------------------------- /example/.owl/baseline/android/custom-threshold.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FormidableLabs/react-native-owl/HEAD/example/.owl/baseline/android/custom-threshold.png -------------------------------------------------------------------------------- /example/android/gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FormidableLabs/react-native-owl/HEAD/example/android/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /ExpoExample/.owl/baseline/android/scroll-to-end.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FormidableLabs/react-native-owl/HEAD/ExpoExample/.owl/baseline/android/scroll-to-end.png -------------------------------------------------------------------------------- /ExpoExample/.owl/baseline/android/custom-threshold.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FormidableLabs/react-native-owl/HEAD/ExpoExample/.owl/baseline/android/custom-threshold.png -------------------------------------------------------------------------------- /lib/client/index.app.ts: -------------------------------------------------------------------------------- 1 | // Setup the Owl client 2 | require('react-native-owl/dist/client').initClient(); 3 | 4 | // Load the app as normal 5 | require('../../../../index'); 6 | -------------------------------------------------------------------------------- /example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FormidableLabs/react-native-owl/HEAD/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FormidableLabs/react-native-owl/HEAD/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /example/.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | arrowParens: 'avoid', 3 | bracketSameLine: true, 4 | bracketSpacing: false, 5 | singleQuote: true, 6 | trailingComma: 'all', 7 | }; 8 | -------------------------------------------------------------------------------- /example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FormidableLabs/react-native-owl/HEAD/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FormidableLabs/react-native-owl/HEAD/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FormidableLabs/react-native-owl/HEAD/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /example/Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # You may use http://rbenv.org/ or https://rvm.io/ to install and use this version 4 | ruby '>= 2.6.10' 5 | 6 | gem 'cocoapods', '>= 1.11.3' 7 | -------------------------------------------------------------------------------- /example/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FormidableLabs/react-native-owl/HEAD/example/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /example/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FormidableLabs/react-native-owl/HEAD/example/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /example/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FormidableLabs/react-native-owl/HEAD/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FormidableLabs/react-native-owl/HEAD/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /react-native.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | dependency: { 3 | platforms: { 4 | android: { 5 | sourceDir: './native/android', 6 | }, 7 | }, 8 | }, 9 | }; 10 | -------------------------------------------------------------------------------- /example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FormidableLabs/react-native-owl/HEAD/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /native/android/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /example/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @format 3 | */ 4 | 5 | import {AppRegistry} from 'react-native'; 6 | import App from './App'; 7 | import {name as appName} from './app.json'; 8 | 9 | AppRegistry.registerComponent(appName, () => App); 10 | -------------------------------------------------------------------------------- /lib/index.ts: -------------------------------------------------------------------------------- 1 | import './matchers'; 2 | 3 | export { takeScreenshot } from './screenshot'; 4 | export { 5 | press, 6 | longPress, 7 | changeText, 8 | scrollTo, 9 | scrollToEnd, 10 | toExist, 11 | reload, 12 | } from './actions'; 13 | -------------------------------------------------------------------------------- /example/android/gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-7.5.1-all.zip 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | -------------------------------------------------------------------------------- /example/ios/OwlDemo/main.m: -------------------------------------------------------------------------------- 1 | #import 2 | 3 | #import "AppDelegate.h" 4 | 5 | int main(int argc, char *argv[]) 6 | { 7 | @autoreleasepool { 8 | return UIApplicationMain(argc, argv, nil, NSStringFromClass([AppDelegate class])); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /lib/utils/file-exists.ts: -------------------------------------------------------------------------------- 1 | import { promises as fs } from 'fs'; 2 | 3 | export const fileExists = async (filePath: string): Promise => { 4 | try { 5 | await fs.access(filePath); 6 | return true; 7 | } catch { 8 | return false; 9 | } 10 | }; 11 | -------------------------------------------------------------------------------- /docs/introduction/work-in-progress.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 3 3 | title: Work In Progress 4 | --- 5 | 6 | # Work In Progress 7 | 8 | ### Future functionality 9 | 10 | - We will automate the launching to the relevant simulator, if not already running, when running tests. 11 | -------------------------------------------------------------------------------- /example/android/settings.gradle: -------------------------------------------------------------------------------- 1 | rootProject.name = 'OwlDemo' 2 | apply from: file("../node_modules/@react-native-community/cli-platform-android/native_modules.gradle"); applyNativeModulesSettingsGradle(settings) 3 | include ':app' 4 | includeBuild('../node_modules/react-native-gradle-plugin') 5 | -------------------------------------------------------------------------------- /native/android/src/main/res/xml/network_security_config.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 10.0.2.2 5 | 6 | -------------------------------------------------------------------------------- /lib/utils/wait-for.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Waits for the specified amount of time (in milliseconds) before resolving. 3 | * @param ms: The number of miliseconds the function should wait for. 4 | */ 5 | export const waitFor = (ms: number): Promise => 6 | new Promise((resolve) => setTimeout(resolve, ms)); 7 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@2.3.0/schema.json", 3 | "changelog": [ 4 | "@svitejs/changesets-changelog-github-compact", 5 | { 6 | "repo": "FormidableLabs/react-native-owl" 7 | } 8 | ], 9 | "access": "public", 10 | "baseBranch": "main" 11 | } -------------------------------------------------------------------------------- /ExpoExample/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .expo/ 3 | dist/ 4 | npm-debug.* 5 | *.jks 6 | *.p8 7 | *.p12 8 | *.key 9 | *.mobileprovision 10 | *.orig.* 11 | web-build/ 12 | 13 | # macOS 14 | .DS_Store 15 | 16 | # Temporary files created by Metro to check the health of the file watcher 17 | .metro-health-check* 18 | -------------------------------------------------------------------------------- /example/ios/OwlDemo.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /lib/client/constants.ts: -------------------------------------------------------------------------------- 1 | export const CHECK_INTERVAL = 500; // ie. Wait for elements to exist 2 | export const MAX_CHECK_TIMEOUT = 10 * 1000; // ie. Element 'Not Found' 3 | export const SOCKET_WAIT_TIMEOUT = 300; // ie. Retry to connect to websocket 4 | 5 | export const ANDROID_WS_HOST = '10.0.2.2'; 6 | export const IOS_WS_HOST = 'localhost'; 7 | -------------------------------------------------------------------------------- /website/.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | /node_modules 3 | 4 | # Production 5 | /build 6 | 7 | # Generated files 8 | .docusaurus 9 | .cache-loader 10 | 11 | # Misc 12 | .DS_Store 13 | .env.local 14 | .env.development.local 15 | .env.test.local 16 | .env.production.local 17 | 18 | npm-debug.log* 19 | yarn-debug.log* 20 | yarn-error.log* 21 | -------------------------------------------------------------------------------- /ExpoExample/index.js: -------------------------------------------------------------------------------- 1 | import { registerRootComponent } from 'expo'; 2 | 3 | import App from './App'; 4 | 5 | // registerRootComponent calls AppRegistry.registerComponent('main', () => App); 6 | // It also ensures that whether you load the app in Expo Go or in a native build, 7 | // the environment is set up appropriately 8 | registerRootComponent(App); 9 | -------------------------------------------------------------------------------- /website/static/images/nearform-logo-white.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /example/__tests__/App-test.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @format 3 | */ 4 | 5 | import 'react-native'; 6 | import React from 'react'; 7 | import App from '../App'; 8 | 9 | // Note: test renderer must be required after react-native. 10 | import renderer from 'react-test-renderer'; 11 | 12 | it('renders correctly', () => { 13 | renderer.create(); 14 | }); 15 | -------------------------------------------------------------------------------- /example/android/app/src/main/res/values/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /native/android/src/main/AndroidManifest.owl.xml: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 6 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /example/owl.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "ios": { 3 | "workspace": "ios/OwlDemo.xcworkspace", 4 | "scheme": "OwlDemo", 5 | "configuration": "Release", 6 | "device": "iPhone 15 Pro", 7 | "quiet": true 8 | }, 9 | "android": { 10 | "packageName": "com.owldemo", 11 | "buildType": "Release", 12 | "quiet": true 13 | }, 14 | "debug": true, 15 | "report": true 16 | } 17 | -------------------------------------------------------------------------------- /ExpoExample/owl.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "ios": { 3 | "workspace": "ios/ExpoExample.xcworkspace", 4 | "scheme": "ExpoExample", 5 | "configuration": "Release", 6 | "device": "iPhone 13 Pro", 7 | "quiet": true 8 | }, 9 | "android": { 10 | "packageName": "com.ExpoExample", 11 | "buildType": "Release", 12 | "quiet": true 13 | }, 14 | "debug": true, 15 | "report": true 16 | } 17 | -------------------------------------------------------------------------------- /scripts/websocket-server.js: -------------------------------------------------------------------------------- 1 | const { startWebSocketServer } = require('../dist/websocket'); 2 | const { Logger } = require('../dist/logger'); 3 | 4 | const debug = process.env.OWL_DEBUG === 'true'; 5 | const logger = new Logger(!!debug); 6 | 7 | (async function () { 8 | try { 9 | await startWebSocketServer(logger); 10 | } catch (e) { 11 | logger.error(`[OWL - Websocket] Websocket server failed to start: ${e}`); 12 | process.exit(1); 13 | } 14 | })(); 15 | -------------------------------------------------------------------------------- /example/android/app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # By default, the flags in this file are appended to flags specified 3 | # in /usr/local/Cellar/android-sdk/24.3.3/tools/proguard/proguard-android.txt 4 | # You can edit the include path and order by changing the proguardFiles 5 | # directive in build.gradle. 6 | # 7 | # For more details, see 8 | # http://developer.android.com/guide/developing/tools/proguard.html 9 | 10 | # Add any project specific keep options here: 11 | -------------------------------------------------------------------------------- /lib/client/trackedElements.test.ts: -------------------------------------------------------------------------------- 1 | import { Logger } from '../logger'; 2 | import { add, get } from './trackedElements'; 3 | 4 | describe('trackedElements.ts', () => { 5 | const logger = new Logger(false); 6 | 7 | it('should check for and return elements that have been added', () => { 8 | const testElement = { ref: { current: null } }; 9 | 10 | expect(get('testId')).toBeFalsy(); 11 | 12 | add(logger, 'testId', testElement); 13 | 14 | expect(get('testId')).toEqual(testElement); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /example/ios/_xcode.env: -------------------------------------------------------------------------------- 1 | # This `.xcode.env` file is versioned and is used to source the environment 2 | # used when running script phases inside Xcode. 3 | # To customize your local environment, you can create an `.xcode.env.local` 4 | # file that is not versioned. 5 | 6 | # NODE_BINARY variable contains the PATH to the node executable. 7 | # 8 | # Customize the NODE_BINARY variable here. 9 | # For example, to use nvm with brew, add the following line 10 | # . "$(brew --prefix nvm)/nvm.sh" --no-use 11 | export NODE_BINARY=$(command -v node) 12 | -------------------------------------------------------------------------------- /example/android/app/src/debug/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /.github/workflows/run-tests.yml: -------------------------------------------------------------------------------- 1 | name: Run Tests 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: main 7 | 8 | jobs: 9 | run-unit-tests: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v4 14 | 15 | - uses: actions/setup-node@v4 16 | with: 17 | node-version: 20 18 | cache: 'yarn' 19 | 20 | - name: Install Dependencies 21 | run: yarn install --frozen-lockfile 22 | 23 | - name: Run Prettier (Check) 24 | run: yarn prettier:check 25 | 26 | - name: Run Typescheck 27 | run: yarn tsc --noEmit 28 | 29 | - name: Run Unit Tests 30 | run: yarn test --coverage 31 | -------------------------------------------------------------------------------- /website/src/components/HomepageFeatures/styles.module.css: -------------------------------------------------------------------------------- 1 | .feature { 2 | background-color: var(--brand-neutral); 3 | padding: 2rem; 4 | margin: 2rem auto; 5 | text-align: center; 6 | } 7 | 8 | @media (min-width: 1416px) { 9 | .feature { 10 | padding: 3rem; 11 | } 12 | } 13 | 14 | .title { 15 | font-size: 2rem; 16 | } 17 | 18 | .subtitle { 19 | font-size: 1.45rem; 20 | line-height: 2.35rem; 21 | } 22 | 23 | .visualExample { 24 | width: 100%; 25 | max-width: 35rem; 26 | max-height: 15rem; 27 | } 28 | 29 | .sectionImageMockup { 30 | max-width: 40rem; 31 | width: 100%; 32 | } 33 | 34 | [data-theme="dark"] .feature { 35 | background-color: var(--ifm-color-gray-800); 36 | } -------------------------------------------------------------------------------- /example/android/build.gradle: -------------------------------------------------------------------------------- 1 | // Top-level build file where you can add configuration options common to all sub-projects/modules. 2 | 3 | buildscript { 4 | ext { 5 | buildToolsVersion = "33.0.0" 6 | minSdkVersion = 21 7 | compileSdkVersion = 33 8 | targetSdkVersion = 33 9 | 10 | // We use NDK 23 which has both M1 support and is the side-by-side NDK version from AGP. 11 | ndkVersion = "23.1.7779620" 12 | } 13 | repositories { 14 | google() 15 | mavenCentral() 16 | } 17 | dependencies { 18 | classpath("com.android.tools.build:gradle:7.3.1") 19 | classpath("com.facebook.react:react-native-gradle-plugin") 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /native/android/templates/ReactNativeOwlModule.java: -------------------------------------------------------------------------------- 1 | package com.formidable.reactnativeowl; 2 | 3 | import androidx.annotation.NonNull; 4 | 5 | import com.facebook.react.bridge.ReactApplicationContext; 6 | import com.facebook.react.bridge.ReactContextBaseJavaModule; 7 | import com.facebook.react.module.annotations.ReactModule; 8 | 9 | @ReactModule(name = ReactNativeOwlModule.NAME) 10 | public class ReactNativeOwlModule extends ReactContextBaseJavaModule { 11 | public static final String NAME = "ReactNativeOwl"; 12 | 13 | public ReactNativeOwlModule(ReactApplicationContext reactContext) { 14 | super(reactContext); 15 | } 16 | 17 | @Override 18 | @NonNull 19 | public String getName() { 20 | return NAME; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /lib/utils/file-exists.test.ts: -------------------------------------------------------------------------------- 1 | import { promises as fs } from 'fs'; 2 | 3 | import { fileExists } from './file-exists'; 4 | 5 | describe('file-exists.ts', () => { 6 | const accessMock = jest.spyOn(fs, 'access'); 7 | 8 | beforeEach(() => { 9 | accessMock.mockReset(); 10 | }); 11 | 12 | it('should check if a file exists - true', async () => { 13 | accessMock.mockResolvedValueOnce(); 14 | 15 | const result = await fileExists('./hello.txt'); 16 | 17 | expect(result).toBe(true); 18 | }); 19 | 20 | it('should check if a file exists - false', async () => { 21 | accessMock.mockRejectedValueOnce(undefined); 22 | 23 | const result = await fileExists('./file-does-not-exist.txt'); 24 | 25 | expect(result).toBe(false); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /website/README.md: -------------------------------------------------------------------------------- 1 | # Website 2 | 3 | This website is built using [Docusaurus 3](https://docusaurus.io/), a modern static website generator. 4 | 5 | ### Installation 6 | 7 | ``` 8 | $ yarn 9 | ``` 10 | 11 | ### Local Development 12 | 13 | ``` 14 | $ yarn start 15 | ``` 16 | 17 | This command starts a local development server and opens up a browser window. Most changes are reflected live without having to restart the server. 18 | 19 | Navigate to: http://localhost:3000/open-source/react-native-owl 20 | 21 | ### Build 22 | 23 | ``` 24 | $ yarn build 25 | $ yarn serve 26 | ``` 27 | 28 | This command generates static content into the `build` directory and can be served using any static contents hosting service. 29 | 30 | Navigate to: http://localhost:5000/open-source/react-native-owl/ 31 | -------------------------------------------------------------------------------- /native/android/src/main/java/com/formidable/reactnativeowl/ReactNativeOwlModule.java: -------------------------------------------------------------------------------- 1 | package com.formidable.reactnativeowl; 2 | 3 | import androidx.annotation.NonNull; 4 | 5 | import com.facebook.react.bridge.ReactApplicationContext; 6 | import com.facebook.react.bridge.ReactContextBaseJavaModule; 7 | import com.facebook.react.module.annotations.ReactModule; 8 | 9 | @ReactModule(name = ReactNativeOwlModule.NAME) 10 | public class ReactNativeOwlModule extends ReactContextBaseJavaModule { 11 | public static final String NAME = "ReactNativeOwl"; 12 | 13 | public ReactNativeOwlModule(ReactApplicationContext reactContext) { 14 | super(reactContext); 15 | } 16 | 17 | @Override 18 | @NonNull 19 | public String getName() { 20 | return NAME; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /example/android/app/src/release/java/com/owldemo/ReactNativeFlipper.java: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Meta Platforms, Inc. and affiliates. 3 | * 4 | *

This source code is licensed under the MIT license found in the LICENSE file in the root 5 | * directory of this source tree. 6 | */ 7 | package com.owldemo; 8 | 9 | import android.content.Context; 10 | import com.facebook.react.ReactInstanceManager; 11 | 12 | /** 13 | * Class responsible of loading Flipper inside your React Native application. This is the release 14 | * flavor of it so it's empty as we don't want to load Flipper. 15 | */ 16 | public class ReactNativeFlipper { 17 | public static void initializeFlipper(Context context, ReactInstanceManager reactInstanceManager) { 18 | // Do nothing as we don't want to initialize Flipper on Release. 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /ExpoExample/app.json: -------------------------------------------------------------------------------- 1 | { 2 | "expo": { 3 | "name": "ExpoExample", 4 | "slug": "ExpoExample", 5 | "version": "1.0.0", 6 | "orientation": "portrait", 7 | "icon": "./assets/icon.png", 8 | "userInterfaceStyle": "light", 9 | "splash": { 10 | "image": "./assets/splash.png", 11 | "resizeMode": "contain", 12 | "backgroundColor": "#ffffff" 13 | }, 14 | "assetBundlePatterns": [ 15 | "**/*" 16 | ], 17 | "ios": { 18 | "supportsTablet": true, 19 | "bundleIdentifier": "com.ExpoExample" 20 | }, 21 | "android": { 22 | "adaptiveIcon": { 23 | "foregroundImage": "./assets/adaptive-icon.png", 24 | "backgroundColor": "#ffffff" 25 | }, 26 | "package": "com.ExpoExample" 27 | }, 28 | "web": { 29 | "favicon": "./assets/favicon.png" 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /example/ios/OwlDemoTests/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | en 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | BNDL 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleSignature 20 | ???? 21 | CFBundleVersion 22 | 1 23 | 24 | 25 | -------------------------------------------------------------------------------- /website/sidebars.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Creating a sidebar enables you to: 3 | - create an ordered group of docs 4 | - render a sidebar for each doc of that group 5 | - provide next/previous navigation 6 | 7 | The sidebars can be generated from the filesystem, or explicitly defined here. 8 | 9 | Create as many sidebars as you want. 10 | */ 11 | 12 | // @ts-check 13 | 14 | /** @type {import('@docusaurus/plugin-content-docs').SidebarsConfig} */ 15 | const sidebars = { 16 | // By default, Docusaurus generates a sidebar from the docs folder structure 17 | tutorialSidebar: [{type: 'autogenerated', dirName: '.'}], 18 | 19 | // But you can create a sidebar manually 20 | /* 21 | tutorialSidebar: [ 22 | { 23 | type: 'category', 24 | label: 'Tutorial', 25 | items: ['hello'], 26 | }, 27 | ], 28 | */ 29 | }; 30 | 31 | module.exports = sidebars; 32 | -------------------------------------------------------------------------------- /ExpoExample/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "expoexample", 3 | "version": "1.0.0", 4 | "scripts": { 5 | "start": "expo start --dev-client", 6 | "android": "expo run:android", 7 | "ios": "expo run:ios", 8 | "web": "expo start --web", 9 | "owl": "node ../dist/cli/index.js", 10 | "owl:build:ios": "yarn owl build --platform ios", 11 | "owl:test:ios": "yarn owl test --platform ios", 12 | "owl:build:android": "yarn owl build --platform android", 13 | "owl:test:android": "yarn owl test --platform android" 14 | }, 15 | "dependencies": { 16 | "expo": "~48.0.9", 17 | "expo-status-bar": "~1.4.4", 18 | "react": "18.2.0", 19 | "react-native": "0.71.4", 20 | "expo-splash-screen": "~0.18.1" 21 | }, 22 | "devDependencies": { 23 | "@babel/core": "^7.20.0", 24 | "jest": "^26.6.3", 25 | "react-native-owl": "link:.." 26 | }, 27 | "private": true 28 | } 29 | -------------------------------------------------------------------------------- /lib/websocketTypes.ts: -------------------------------------------------------------------------------- 1 | export type SOCKET_TEST_ACTION = 2 | | 'PRESS' 3 | | 'LONG_PRESS' 4 | | 'CHANGE_TEXT' 5 | | 'SCROLL_TO' 6 | | 'SCROLL_TO_END'; 7 | 8 | export type LAYOUT_ACTION = 'EXISTS'; 9 | 10 | export type SOCKET_SCROLL_TO_VALUE = { 11 | x?: number | undefined; 12 | y?: number | undefined; 13 | animated?: boolean | undefined; 14 | }; 15 | 16 | export type SOCKET_TEST_REQUEST_VALUE = string | SOCKET_SCROLL_TO_VALUE; 17 | 18 | export type SOCKET_TEST_REQUEST = 19 | | { 20 | type: 'ACTION'; 21 | action: SOCKET_TEST_ACTION; 22 | testID: string; 23 | value?: SOCKET_TEST_REQUEST_VALUE; 24 | } 25 | | { 26 | type: 'LAYOUT'; 27 | action: LAYOUT_ACTION; 28 | testID: string; 29 | }; 30 | 31 | export type SOCKET_CLIENT_RESPONSE = 32 | | { 33 | type: 'DONE'; 34 | } 35 | | { 36 | type: 'NOT_FOUND'; 37 | testID: string; 38 | } 39 | | { 40 | type: 'ERROR'; 41 | message: string; 42 | testID: string; 43 | }; 44 | -------------------------------------------------------------------------------- /ExpoExample/metro.config.js: -------------------------------------------------------------------------------- 1 | // Learn more https://docs.expo.io/guides/customizing-metro 2 | const { getDefaultConfig } = require('expo/metro-config'); 3 | const path = require('path'); 4 | 5 | const config = getDefaultConfig(__dirname); 6 | 7 | // As the example project uses `link:../` for react-native-owl, which creates a symlink, we need to manually map the project so it is properly used my Metro. 8 | // This will not be required by other projects using react-native-owl installed from a package repository. 9 | 10 | const extraNodeModules = { 11 | 'react-native-owl': path.resolve(path.join(__dirname, '..')), 12 | }; 13 | const watchFolders = [path.resolve(path.join(__dirname, '..', 'dist'))]; 14 | 15 | module.exports = { 16 | ...config, 17 | resolver: { 18 | ...config.resolver, 19 | extraNodeModules: new Proxy(extraNodeModules, { 20 | get: (target, name) => 21 | name in target 22 | ? target[name] 23 | : path.join(process.cwd(), `node_modules/${name}`), 24 | }), 25 | }, 26 | watchFolders, 27 | }; 28 | -------------------------------------------------------------------------------- /native/android/src/main/java/com/formidable/reactnativeowl/ReactNativeOwlPackage.java: -------------------------------------------------------------------------------- 1 | package com.formidable.reactnativeowl; 2 | 3 | import androidx.annotation.NonNull; 4 | 5 | import com.facebook.react.ReactPackage; 6 | import com.facebook.react.bridge.NativeModule; 7 | import com.facebook.react.bridge.ReactApplicationContext; 8 | import com.facebook.react.uimanager.ViewManager; 9 | 10 | import java.util.ArrayList; 11 | import java.util.Collections; 12 | import java.util.List; 13 | 14 | public class ReactNativeOwlPackage implements ReactPackage { 15 | @NonNull 16 | @Override 17 | public List createNativeModules(@NonNull ReactApplicationContext reactContext) { 18 | List modules = new ArrayList<>(); 19 | modules.add(new ReactNativeOwlModule(reactContext)); 20 | return modules; 21 | } 22 | 23 | @NonNull 24 | @Override 25 | public List createViewManagers(@NonNull ReactApplicationContext reactContext) { 26 | return Collections.emptyList(); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /lib/logger.ts: -------------------------------------------------------------------------------- 1 | export class Logger { 2 | isEnabled: boolean; 3 | 4 | constructor(isEnabled: boolean = false) { 5 | this.isEnabled = isEnabled; 6 | } 7 | 8 | /** Will only output when the debug flag in the config is on. */ 9 | info(message?: any, ...optionalParams: any[]) { 10 | if (this.isEnabled) { 11 | console.info(message, ...optionalParams); 12 | } 13 | } 14 | 15 | /** Will only output when the debug flag in the config is on. */ 16 | warn(message?: any, ...optionalParams: any[]) { 17 | if (this.isEnabled) { 18 | console.warn(message, ...optionalParams); 19 | } 20 | } 21 | 22 | /** Will only output when the debug flag in the config is on. */ 23 | error(message?: any, ...optionalParams: any[]) { 24 | if (this.isEnabled) { 25 | console.error(message, ...optionalParams); 26 | } 27 | } 28 | 29 | /** Will always print output to the terminal - not depending on the debug flag. */ 30 | print(message?: any, ...optionalParams: any[]) { 31 | console.log(message, ...optionalParams); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /docs/cli/building-the-app.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 1 3 | --- 4 | 5 | import Tabs from '@theme/Tabs'; 6 | import TabItem from '@theme/TabItem'; 7 | 8 | # Building the app 9 | 10 | Before the app can be tested, it must be built. 11 | 12 | #### Options 13 | 14 | | Name | Required | Default | Options/Types | Description | 15 | | ---------------- | -------- | ----------------- | --------------- | --------------------------------------- | 16 | | `config`, `-c` | false | ./owl.config.json | String | Path to the configuration file | 17 | | `platform`, `-p` | true | - | `ios`,`android` | The platform the app should be built on | 18 | 19 | #### Examples 20 | 21 | 22 | 23 | 24 | ```bash 25 | npx owl build --platform ios --config ./owl.config.json 26 | ``` 27 | 28 | 29 | 30 | 31 | ```bash 32 | yarn owl build --platform ios --config ./owl.config.json 33 | ``` 34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /lib/client/trackedElements.ts: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | PressableProps, 4 | TextInputProps, 5 | TouchableWithoutFeedbackProps, 6 | } from 'react-native'; 7 | import { Logger } from '../logger'; 8 | 9 | export type TrackedElementData = { 10 | ref: React.RefObject; 11 | onPress?: 12 | | TouchableWithoutFeedbackProps['onPress'] 13 | | PressableProps['onPress']; 14 | onLongPress?: 15 | | TouchableWithoutFeedbackProps['onLongPress'] 16 | | PressableProps['onLongPress']; 17 | onChangeText?: TextInputProps['onChangeText']; 18 | }; 19 | 20 | /** 21 | * A masic map of tracked elements, that we use to keep track of elements 22 | * so that we can perform actions on them in future 23 | */ 24 | const trackedElements: Record = {}; 25 | 26 | export const get = (ID: string): TrackedElementData | undefined => 27 | trackedElements[ID]; 28 | 29 | export const add = (logger: Logger, ID: string, data: TrackedElementData) => { 30 | trackedElements[ID] = data; 31 | 32 | logger.info(`[OWL - Tracker] Tracking element with ${ID}`); 33 | }; 34 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | push: 4 | branches: 5 | - main 6 | jobs: 7 | release: 8 | name: Release 9 | runs-on: ubuntu-latest 10 | permissions: 11 | contents: write 12 | id-token: write 13 | issues: write 14 | repository-projects: write 15 | deployments: write 16 | packages: write 17 | pull-requests: write 18 | steps: 19 | - uses: actions/checkout@v2 20 | - uses: actions/setup-node@v3 21 | with: 22 | node-version: 20 23 | 24 | - name: Install dependencies 25 | run: yarn install --frozen-lockfile 26 | 27 | - name: Typescript Build 28 | run: yarn build 29 | 30 | - name: Unit Tests 31 | run: yarn test 32 | 33 | - name: PR or Publish 34 | id: changesets 35 | uses: changesets/action@v1 36 | with: 37 | version: yarn changeset version 38 | publish: yarn changeset publish 39 | env: 40 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 41 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 42 | -------------------------------------------------------------------------------- /example/android/app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 12 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Formidable 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /example/ios/OwlDemo/Images.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "iphone", 5 | "scale" : "2x", 6 | "size" : "20x20" 7 | }, 8 | { 9 | "idiom" : "iphone", 10 | "scale" : "3x", 11 | "size" : "20x20" 12 | }, 13 | { 14 | "idiom" : "iphone", 15 | "scale" : "2x", 16 | "size" : "29x29" 17 | }, 18 | { 19 | "idiom" : "iphone", 20 | "scale" : "3x", 21 | "size" : "29x29" 22 | }, 23 | { 24 | "idiom" : "iphone", 25 | "scale" : "2x", 26 | "size" : "40x40" 27 | }, 28 | { 29 | "idiom" : "iphone", 30 | "scale" : "3x", 31 | "size" : "40x40" 32 | }, 33 | { 34 | "idiom" : "iphone", 35 | "scale" : "2x", 36 | "size" : "60x60" 37 | }, 38 | { 39 | "idiom" : "iphone", 40 | "scale" : "3x", 41 | "size" : "60x60" 42 | }, 43 | { 44 | "idiom" : "ios-marketing", 45 | "scale" : "1x", 46 | "size" : "1024x1024" 47 | } 48 | ], 49 | "info" : { 50 | "author" : "xcode", 51 | "version" : 1 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /example/metro.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Metro configuration for React Native 3 | * https://github.com/facebook/react-native 4 | * 5 | * @format 6 | */ 7 | const path = require('path'); 8 | 9 | // As the example project uses `link:../` for react-native-owl, which creates a symlink, we need to manually map the project so it is properly used my Metro. 10 | // This will not be required by other projects using react-native-owl installed from a package repository. 11 | 12 | const extraNodeModules = { 13 | 'react-native-owl': path.resolve(path.join(__dirname, '..')), 14 | }; 15 | const watchFolders = [path.resolve(path.join(__dirname, '..', 'dist'))]; 16 | 17 | module.exports = { 18 | transformer: { 19 | getTransformOptions: async () => ({ 20 | transform: { 21 | experimentalImportSupport: false, 22 | inlineRequires: true, 23 | }, 24 | }), 25 | }, 26 | resolver: { 27 | extraNodeModules: new Proxy(extraNodeModules, { 28 | get: (target, name) => 29 | name in target 30 | ? target[name] 31 | : path.join(process.cwd(), `node_modules/${name}`), 32 | }), 33 | }, 34 | watchFolders, 35 | }; 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### Node ### 2 | # Mac 3 | .DS_Store 4 | 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | yarn-debug.log* 10 | yarn-error.log* 11 | lerna-debug.log* 12 | 13 | # Diagnostic reports (https://nodejs.org/api/report.html) 14 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 15 | 16 | # Runtime data 17 | pids 18 | *.pid 19 | *.seed 20 | *.pid.lock 21 | 22 | # Directory for instrumented libs generated by jscoverage/JSCover 23 | lib-cov 24 | 25 | # Coverage directory used by tools like istanbul 26 | coverage 27 | *.lcov 28 | 29 | # Compiled binary addons (https://nodejs.org/api/addons.html) 30 | build/Release 31 | 32 | # Dependency directories 33 | node_modules/ 34 | jspm_packages/ 35 | 36 | # TypeScript v1 declaration files 37 | typings/ 38 | 39 | # TypeScript cache 40 | *.tsbuildinfo 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Output of 'npm pack' 49 | *.tgz 50 | 51 | # Yarn Integrity file 52 | .yarn-integrity 53 | 54 | # dotenv environment variables file 55 | .env 56 | .env.test 57 | .env*.local 58 | 59 | # generated js 60 | dist/ 61 | 62 | # Expo Example prebuild generated files 63 | ExpoExample/android 64 | ExpoExample/ios 65 | -------------------------------------------------------------------------------- /example/.gitignore: -------------------------------------------------------------------------------- 1 | # OSX 2 | # 3 | .DS_Store 4 | 5 | # Xcode 6 | # 7 | build/ 8 | *.pbxuser 9 | !default.pbxuser 10 | *.mode1v3 11 | !default.mode1v3 12 | *.mode2v3 13 | !default.mode2v3 14 | *.perspectivev3 15 | !default.perspectivev3 16 | xcuserdata 17 | *.xccheckout 18 | *.moved-aside 19 | DerivedData 20 | *.hmap 21 | *.ipa 22 | *.xcuserstate 23 | ios/.xcode.env.local 24 | 25 | # Android/IntelliJ 26 | # 27 | build/ 28 | .idea 29 | .gradle 30 | local.properties 31 | *.iml 32 | *.hprof 33 | .cxx/ 34 | *.keystore 35 | !debug.keystore 36 | 37 | # node.js 38 | # 39 | node_modules/ 40 | npm-debug.log 41 | yarn-error.log 42 | 43 | # fastlane 44 | # 45 | # It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the 46 | # screenshots whenever they are needed. 47 | # For more information about the recommended setup visit: 48 | # https://docs.fastlane.tools/best-practices/source-control/ 49 | 50 | **/fastlane/report.xml 51 | **/fastlane/Preview.html 52 | **/fastlane/screenshots 53 | **/fastlane/test_output 54 | 55 | # Bundle artifact 56 | *.jsbundle 57 | 58 | # Ruby / CocoaPods 59 | /ios/Pods/ 60 | /vendor/bundle/ 61 | 62 | # Temporary files created by Metro to check the health of the file watcher 63 | .metro-health-check* 64 | -------------------------------------------------------------------------------- /lib/client/websocket.ts: -------------------------------------------------------------------------------- 1 | import { WEBSOCKET_PORT } from '../constants'; 2 | 3 | import { Logger } from '../logger'; 4 | import { ANDROID_WS_HOST, IOS_WS_HOST } from './constants'; 5 | 6 | /** 7 | * Create a connection to the websocket server, 8 | * and call the onMessage callback when it receives any messages. 9 | */ 10 | export const initWebSocket = ( 11 | logger: Logger, 12 | platform: 'android' | 'ios', 13 | onMessage: (message: string) => void 14 | ): Promise => { 15 | const ipAddress = platform === 'android' ? ANDROID_WS_HOST : IOS_WS_HOST; 16 | 17 | const ws = new WebSocket(`ws://${ipAddress}:${WEBSOCKET_PORT}`); 18 | 19 | return new Promise((resolve, reject) => { 20 | ws.onopen = () => { 21 | logger.info('[OWL - Websocket] onopen'); 22 | 23 | ws.send('OWL Client Connected!'); 24 | 25 | resolve(ws); 26 | }; 27 | 28 | ws.onmessage = (e) => { 29 | logger.info(`[OWL - Websocket] onmessage: ${e.data}`); 30 | 31 | onMessage(e.data.toString()); 32 | }; 33 | 34 | ws.onerror = (e) => { 35 | logger.info(`[OWL - Websocket] onerror: ${e.message}`); 36 | }; 37 | 38 | ws.onclose = (e) => { 39 | logger.info(`[OWL - Websocket] onclose: ${e.reason}`); 40 | 41 | reject(e); 42 | }; 43 | }); 44 | }; 45 | -------------------------------------------------------------------------------- /example/ios/OwlDemo/AppDelegate.mm: -------------------------------------------------------------------------------- 1 | #import "AppDelegate.h" 2 | 3 | #import 4 | 5 | @implementation AppDelegate 6 | 7 | - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions 8 | { 9 | self.moduleName = @"OwlDemo"; 10 | // You can add your custom initial props in the dictionary below. 11 | // They will be passed down to the ViewController used by React Native. 12 | self.initialProps = @{}; 13 | 14 | return [super application:application didFinishLaunchingWithOptions:launchOptions]; 15 | } 16 | 17 | - (NSURL *)sourceURLForBridge:(RCTBridge *)bridge 18 | { 19 | #if DEBUG 20 | return [[RCTBundleURLProvider sharedSettings] jsBundleURLForBundleRoot:@"index"]; 21 | #else 22 | return [[NSBundle mainBundle] URLForResource:@"main" withExtension:@"jsbundle"]; 23 | #endif 24 | } 25 | 26 | /// This method controls whether the `concurrentRoot`feature of React18 is turned on or off. 27 | /// 28 | /// @see: https://reactjs.org/blog/2022/03/29/react-v18.html 29 | /// @note: This requires to be rendering on Fabric (i.e. on the New Architecture). 30 | /// @return: `true` if the `concurrentRoot` feature is enabled. Otherwise, it returns `false`. 31 | - (BOOL)concurrentRootEnabled 32 | { 33 | return true; 34 | } 35 | 36 | @end 37 | -------------------------------------------------------------------------------- /website/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-native-owl-website", 3 | "version": "0.0.0", 4 | "private": true, 5 | "scripts": { 6 | "docusaurus": "docusaurus", 7 | "start": "docusaurus start", 8 | "build": "docusaurus build --out-dir build/open-source/react-native-owl", 9 | "swizzle": "docusaurus swizzle", 10 | "clear": "docusaurus clear", 11 | "write-translations": "docusaurus write-translations", 12 | "write-heading-ids": "docusaurus write-heading-ids" 13 | }, 14 | "dependencies": { 15 | "@docusaurus/core": "^3.2.1", 16 | "@docusaurus/preset-classic": "^3.2.1", 17 | "@mdx-js/react": "^3.0.0", 18 | "clsx": "^1.2.1", 19 | "prism-react-renderer": "^2.3.0", 20 | "react": "^18.0.0", 21 | "react-dom": "^18.0.0" 22 | }, 23 | "devDependencies": { 24 | "@docusaurus/module-type-aliases": "^3.2.1", 25 | "@docusaurus/theme-classic": "^3.2.1", 26 | "@docusaurus/tsconfig": "^3.2.1", 27 | "@docusaurus/types": "^3.2.1", 28 | "typescript": "~5.2.2" 29 | }, 30 | "browserslist": { 31 | "production": [ 32 | ">0.5%", 33 | "not dead", 34 | "not op_mini all" 35 | ], 36 | "development": [ 37 | "last 1 chrome version", 38 | "last 1 firefox version", 39 | "last 1 safari version" 40 | ] 41 | }, 42 | "engines": { 43 | "node": ">=18.0.0" 44 | } 45 | } -------------------------------------------------------------------------------- /lib/client/websocket.test.ts: -------------------------------------------------------------------------------- 1 | import { Logger } from '../logger'; 2 | import WS from 'jest-websocket-mock'; 3 | import { WEBSOCKET_PORT } from '../constants'; 4 | import { ANDROID_WS_HOST, IOS_WS_HOST } from './constants'; 5 | 6 | describe('websocket.ts', () => { 7 | const logger = new Logger(false); 8 | 9 | const onMessage = jest.fn(); 10 | 11 | beforeEach(() => { 12 | onMessage.mockClear(); 13 | }); 14 | 15 | afterEach(() => { 16 | WS.clean(); 17 | }); 18 | 19 | it('should connect to the WS server and receive messages on iOS', async () => { 20 | const server = new WS(`ws://${IOS_WS_HOST}:${WEBSOCKET_PORT}`); 21 | 22 | await require('./websocket').initWebSocket(logger, 'ios', onMessage); 23 | 24 | await server.connected; 25 | 26 | server.send('data'); 27 | 28 | expect(onMessage).toHaveBeenCalledWith('data'); 29 | }); 30 | 31 | it('should connect to the WS server and receive messages on Android', async () => { 32 | const server = new WS(`ws://${ANDROID_WS_HOST}:${WEBSOCKET_PORT}`); 33 | 34 | await require('./websocket').initWebSocket(logger, 'android', onMessage); 35 | 36 | await server.connected; 37 | 38 | server.send('data'); 39 | 40 | expect(onMessage).toHaveBeenCalledWith('data'); 41 | }); 42 | 43 | it('should reject when failing to connect to a WS server', async () => { 44 | await expect( 45 | require('./websocket').initWebSocket(logger, 'ios', onMessage) 46 | ).rejects.toBeTruthy(); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # react-native-owl 2 | 3 | ## 1.5.0 4 | 5 | ### Minor Changes 6 | 7 | - Ability to pass along Jest testNamePattern when the argument is provided ([#182](https://github.com/FormidableLabs/react-native-owl/pull/182)) 8 | 9 | ### Patch Changes 10 | 11 | - fix alias to avoid collision ([#184](https://github.com/FormidableLabs/react-native-owl/pull/184)) 12 | 13 | ## 1.4.0 14 | 15 | ### Minor Changes 16 | 17 | - implements a new `testPathPattern` option for the CLI, allowing users to run tests for specific path patterns. ([#180](https://github.com/FormidableLabs/react-native-owl/pull/180)) 18 | 19 | ## 1.3.0 20 | 21 | ### Minor Changes 22 | 23 | - Switch Jest config to CLI options for Jest 28+ support ([#168](https://github.com/FormidableLabs/react-native-owl/pull/168)) 24 | 25 | ### Patch Changes 26 | 27 | - Disable Flipper on the example iOS app to allow for faster builds ([#170](https://github.com/FormidableLabs/react-native-owl/pull/170)) 28 | - Update getting Started docs ([#171](https://github.com/FormidableLabs/react-native-owl/pull/171)) 29 | 30 | ## 1.2.3 31 | 32 | ### Patch Changes 33 | 34 | - Update example project to use RN 0.71 ([#151](https://github.com/FormidableLabs/react-native-owl/pull/151)) 35 | 36 | - Handle iOS scheme with space ([#150](https://github.com/FormidableLabs/react-native-owl/pull/150)) 37 | 38 | ## 1.2.2 39 | 40 | ### Patch Changes 41 | 42 | - Addition of GitHub release workflow ([#155](https://github.com/FormidableLabs/react-native-owl/pull/155)) 43 | -------------------------------------------------------------------------------- /docs/api/matchers.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 3 3 | --- 4 | 5 | # Matchers 6 | 7 | ### toMatchBaseline(options: \{threshold?: number\} = \{threshold: 0.1\}) 8 | 9 | This custom Jest matcher will try to find and compare the baseline screenshot by using the path of the latest screenshot (returned by `takeScreenshot()`). You will have to take a screenshot before using, and pass the path of that screenshot to the expect method. 10 | 11 | #### Example 12 | 13 | ```js title="__tests__/App.owl.tsx" 14 | import { takeScreenshot } from 'react-native-owl'; 15 | 16 | describe('App.tsx', () => { 17 | it('takes a screenshot of the first screen', async () => { 18 | const screen = await takeScreenshot('homescreen'); 19 | 20 | // highlight-next-line 21 | expect(screen).toMatchBaseline(); 22 | 23 | // Or with a custom threshold 24 | expect(screen).toMatchBaseline({ threshold: 0.2 }); 25 | }); 26 | }); 27 | ``` 28 | 29 | The first time this test is run, or when run with the `--update` flag, the `.toMatchBaseline` expectation will always be successful. 30 | 31 | On subsequent test runs, the screenshot captured by `takeScreenshot` (and stored in `/current`) will be compared to the baseline screenshot. **_Any_** differences will cause the expectation to fail, and the report to be generated. 32 | 33 | The `threshold` option allows you to customise the threshold for [pixelmatch](https://github.com/mapbox/pixelmatch#pixelmatchimg1-img2-output-width-height-options) which Owl uses under the hood. 34 | -------------------------------------------------------------------------------- /example/android/app/src/main/java/com/owldemo/MainActivity.java: -------------------------------------------------------------------------------- 1 | package com.owldemo; 2 | 3 | import com.facebook.react.ReactActivity; 4 | import com.facebook.react.ReactActivityDelegate; 5 | import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint; 6 | import com.facebook.react.defaults.DefaultReactActivityDelegate; 7 | 8 | public class MainActivity extends ReactActivity { 9 | 10 | /** 11 | * Returns the name of the main component registered from JavaScript. This is used to schedule 12 | * rendering of the component. 13 | */ 14 | @Override 15 | protected String getMainComponentName() { 16 | return "OwlDemo"; 17 | } 18 | 19 | /** 20 | * Returns the instance of the {@link ReactActivityDelegate}. Here we use a util class {@link 21 | * DefaultReactActivityDelegate} which allows you to easily enable Fabric and Concurrent React 22 | * (aka React 18) with two boolean flags. 23 | */ 24 | @Override 25 | protected ReactActivityDelegate createReactActivityDelegate() { 26 | return new DefaultReactActivityDelegate( 27 | this, 28 | getMainComponentName(), 29 | // If you opted-in for the New Architecture, we enable the Fabric Renderer. 30 | DefaultNewArchitectureEntryPoint.getFabricEnabled(), // fabricEnabled 31 | // If you opted-in for the New Architecture, we enable Concurrent React (i.e. React 18). 32 | DefaultNewArchitectureEntryPoint.getConcurrentReactEnabled() // concurrentRootEnabled 33 | ); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "owldemo", 3 | "version": "0.0.1", 4 | "private": true, 5 | "scripts": { 6 | "android": "react-native run-android", 7 | "ios": "react-native run-ios", 8 | "owl": "node ../dist/cli/index.js", 9 | "owl:build:ios": "yarn owl build --platform ios", 10 | "owl:test:ios": "yarn owl test --platform ios", 11 | "owl:test:update:ios": "yarn owl test --platform ios --update", 12 | "owl:test:update:android": "yarn owl test --platform android --update", 13 | "owl:build:android": "yarn owl build --platform android", 14 | "owl:test:android": "yarn owl test --platform android", 15 | "lint": "eslint .", 16 | "start": "react-native start", 17 | "test": "jest" 18 | }, 19 | "dependencies": { 20 | "react": "18.2.0", 21 | "react-native": "0.71.19", 22 | "ts-jest": "^29.1.2" 23 | }, 24 | "devDependencies": { 25 | "@babel/core": "^7.20.0", 26 | "@babel/preset-env": "^7.20.0", 27 | "@babel/runtime": "^7.20.0", 28 | "@react-native-community/eslint-config": "^3.2.0", 29 | "@tsconfig/react-native": "^2.0.2", 30 | "@types/jest": "^29.2.1", 31 | "@types/react": "^18.0.24", 32 | "@types/react-test-renderer": "^18.0.0", 33 | "babel-jest": "^29.2.1", 34 | "eslint": "^8.19.0", 35 | "jest": "^27.5.1", 36 | "metro-react-native-babel-preset": "0.73.9", 37 | "prettier": "^2.4.1", 38 | "react-native-owl": "link:..", 39 | "react-test-renderer": "18.2.0", 40 | "typescript": "4.8.4" 41 | }, 42 | "jest": { 43 | "preset": "ts-jest" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /lib/utils/adb.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import execa from 'execa'; 3 | import { ConfigAndroid } from '../types'; 4 | 5 | export const adbInstall = async ({ 6 | debug, 7 | binaryPath, 8 | buildType = 'Release', 9 | }: { 10 | debug?: boolean; 11 | binaryPath?: ConfigAndroid['binaryPath']; 12 | buildType?: ConfigAndroid['buildType']; 13 | }) => { 14 | const stdio = debug ? 'inherit' : 'ignore'; 15 | const DEFAULT_APK_DIR = `/android/app/build/outputs/apk/${buildType.toLowerCase()}/`; 16 | const cwd = binaryPath 17 | ? path.dirname(binaryPath) 18 | : path.join(process.cwd(), DEFAULT_APK_DIR); 19 | 20 | const appFilename = binaryPath 21 | ? path.basename(binaryPath) 22 | : `app-${buildType.toLowerCase()}.apk`; 23 | const appPath = path.join(cwd, appFilename); 24 | 25 | const command = `adb install -r ${appPath}`; 26 | await execa.command(command, { stdio }); 27 | }; 28 | 29 | export const adbTerminate = async ({ 30 | debug, 31 | packageName, 32 | }: { 33 | debug?: boolean; 34 | packageName: string; 35 | }) => { 36 | const stdio = debug ? 'inherit' : 'ignore'; 37 | 38 | const command = `adb shell am force-stop ${packageName}`; 39 | await execa.command(command, { stdio }); 40 | }; 41 | 42 | export const adbLaunch = async ({ 43 | debug, 44 | packageName, 45 | }: { 46 | debug?: boolean; 47 | packageName: string; 48 | }) => { 49 | const stdio = debug ? 'inherit' : 'ignore'; 50 | 51 | const command = `adb shell monkey -p "${packageName}" -c android.intent.category.LAUNCHER 1`; 52 | await execa.command(command, { stdio }); 53 | }; 54 | -------------------------------------------------------------------------------- /website/src/components/Hero/styles.module.css: -------------------------------------------------------------------------------- 1 | /** 2 | * CSS files with the .module.css suffix will be treated as CSS modules 3 | * and scoped locally. 4 | */ 5 | 6 | .hero { 7 | color: var(--ifm-color-white); 8 | background-color: var(--brand-blue); 9 | position: relative; 10 | padding: 1.5rem; 11 | margin-bottom: 5rem; 12 | z-index: 1; 13 | } 14 | 15 | .hero:after { 16 | background: inherit; 17 | border-bottom: 11px solid var(--brand-primary); 18 | content: ''; 19 | display: block; 20 | height: 50%; 21 | position: absolute; 22 | left: 0; 23 | right: 0; 24 | bottom: 0; 25 | z-index: -1; 26 | transform: skewY(-1.5deg); 27 | transform-origin: 100%; 28 | backface-visibility: hidden; 29 | -webkit-backface-visibility: hidden; 30 | } 31 | 32 | @media (min-width: 1416px) { 33 | .hero { 34 | padding: 3rem 0 4rem; 35 | margin-bottom: 10rem; 36 | } 37 | } 38 | 39 | .heroSubtitle { 40 | font-weight: 500; 41 | margin-bottom: 2rem; 42 | } 43 | 44 | .logoBadge { 45 | max-width: 8rem; 46 | margin-bottom: 0.75rem; 47 | } 48 | 49 | .codeSample { 50 | width: 100%; 51 | } 52 | 53 | .codeSampleWrapper { 54 | margin-top: 2rem; 55 | } 56 | 57 | @media (min-width: 1025px) { 58 | .codeSampleWrapper { 59 | display: flex; 60 | align-items: center; 61 | margin-top: 0; 62 | } 63 | } 64 | 65 | .buttons { 66 | display: flex; 67 | align-items: center; 68 | } 69 | 70 | .buttons .ctaButton { 71 | border-radius: 3px; 72 | border-color: var(--brand-primary); 73 | background-color: var(--brand-primary); 74 | padding: 0.75rem 1.75rem; 75 | } 76 | -------------------------------------------------------------------------------- /lib/websocket.ts: -------------------------------------------------------------------------------- 1 | import WebSocket from 'ws'; 2 | import { WEBSOCKET_PORT } from './constants'; 3 | 4 | import { Logger } from './logger'; 5 | 6 | export const startWebSocketServer = async ( 7 | logger: Logger 8 | ): Promise => { 9 | const wss = new WebSocket.Server({ port: WEBSOCKET_PORT }); 10 | 11 | return new Promise((resolve) => { 12 | wss.on('connection', (ws) => { 13 | ws.on('message', (message) => { 14 | logger.info( 15 | `[OWL - WebSocket] The server received a message: ${message.toString()}` 16 | ); 17 | 18 | wss.clients.forEach((client) => { 19 | if (client !== ws && client.readyState === WebSocket.OPEN) { 20 | client.send(message.toString()); 21 | } 22 | }); 23 | }); 24 | 25 | ws.on('error', (error) => { 26 | logger.error(`[OWL - WebSocket] Error:`, error); 27 | }); 28 | }); 29 | 30 | wss.on('listening', () => { 31 | logger.info(`[OWL - WebSocket] Listening on port ${wss.options.port}.`); 32 | 33 | return resolve(wss); 34 | }); 35 | }); 36 | }; 37 | 38 | export const createWebSocketClient = async ( 39 | logger: Logger, 40 | onMessage: (message: string) => void 41 | ): Promise => { 42 | const wsClient = new WebSocket(`ws://localhost:${WEBSOCKET_PORT}`); 43 | 44 | return new Promise((resolve) => { 45 | wsClient.on('open', () => resolve(wsClient)); 46 | 47 | wsClient.on('message', (message) => { 48 | logger.info( 49 | `[OWL - WebSocket] The client received a message: ${message.toString()}.` 50 | ); 51 | 52 | onMessage(message.toString()); 53 | }); 54 | }); 55 | }; 56 | -------------------------------------------------------------------------------- /website/src/components/Hero/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import clsx from 'clsx'; 3 | import Link from '@docusaurus/Link'; 4 | import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; 5 | import CodeBlock from '@theme/CodeBlock'; 6 | 7 | import styles from './styles.module.css'; 8 | 9 | const LogoBadge = require('../../../static/images/badge.svg').default; 10 | 11 | const heroExample = `describe('App.tsx', () => { 12 | it('presses a button & takes a screenshot', async () => { 13 | await press('button'); 14 | 15 | const screen = await takeScreenshot('homescreen'); 16 | 17 | expect(screen).toMatchBaseline(); 18 | }); 19 | });`; 20 | 21 | export const Hero = () => { 22 | const { siteConfig } = useDocusaurusContext(); 23 | 24 | return ( 25 |

26 |
27 |
28 |
29 | 30 |

{siteConfig.title}

31 |

{siteConfig.tagline}

32 | 33 |
34 | 38 | Get Started 39 | 40 |
41 |
42 | 43 |
44 | {heroExample} 45 |
46 |
47 |
48 |
49 | ); 50 | }; 51 | -------------------------------------------------------------------------------- /native/android/templates/ReactNativeOwlModuleOwl.java: -------------------------------------------------------------------------------- 1 | package com.formidable.reactnativeowl; 2 | 3 | import android.app.Activity; 4 | import android.view.View; 5 | 6 | import androidx.annotation.NonNull; 7 | 8 | import com.facebook.react.bridge.ReactApplicationContext; 9 | import com.facebook.react.bridge.ReactContextBaseJavaModule; 10 | import com.facebook.react.bridge.UiThreadUtil; 11 | import com.facebook.react.module.annotations.ReactModule; 12 | 13 | @ReactModule(name = ReactNativeOwlModule.NAME) 14 | public class ReactNativeOwlModule extends ReactContextBaseJavaModule { 15 | public static final String NAME = "ReactNativeOwl"; 16 | 17 | private static final int UI_FLAG_IMMERSIVE = View.SYSTEM_UI_FLAG_LAYOUT_STABLE 18 | | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION 19 | | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN 20 | | View.SYSTEM_UI_FLAG_HIDE_NAVIGATION // hide nav bar 21 | | View.SYSTEM_UI_FLAG_FULLSCREEN // hide status bar 22 | | View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY; 23 | 24 | public ReactNativeOwlModule(ReactApplicationContext reactContext) { 25 | super(reactContext); 26 | } 27 | 28 | @Override 29 | public void initialize() { 30 | hideNavigationBar(); 31 | } 32 | 33 | private void hideNavigationBar() { 34 | UiThreadUtil.runOnUiThread(new Runnable() { 35 | @Override 36 | public void run() { 37 | final Activity activity = getCurrentActivity(); 38 | activity.getWindow().getDecorView().setSystemUiVisibility(UI_FLAG_IMMERSIVE); 39 | } 40 | }); 41 | } 42 | 43 | @Override 44 | @NonNull 45 | public String getName() { 46 | return NAME; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /website/src/components/HomepageFeatures/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import styles from './styles.module.css'; 4 | import {useBaseUrlUtils} from "@docusaurus/useBaseUrl"; 5 | 6 | const FeatureList = [ 7 | { 8 | title: 'Visual regression testing', 9 | imageSource: '/images/homepage/diff.png', 10 | description: ( 11 | <>We've created a simple api for capturing and comparing screenshots. 12 | ), 13 | }, 14 | { 15 | title: 'Take screenshots from your app', 16 | imageSource: '/images/homepage/mockup.png', 17 | description: ( 18 | <> 19 | Owl was designed to make it easy to add visual regression testing to your 20 | react native app. 21 | 22 | ), 23 | }, 24 | { 25 | title: 'View the differences', 26 | imageSource: '/images/homepage/report.png', 27 | description: ( 28 | <> 29 | Owl clearly highlights all visual differences, so no need to play 30 | spot-the-difference yourself! 31 | 32 | ), 33 | }, 34 | ]; 35 | 36 | export const HomepageFeatures: React.FC = () => { 37 | const { withBaseUrl } = useBaseUrlUtils() 38 | return ( 39 | <> 40 | {FeatureList.map(({ title, description, imageSource }, idx) => { 41 | return ( 42 |
43 |

{title}

44 |

{description}

45 | 46 |
47 | {!!imageSource && ( 48 | 52 | )} 53 |
54 |
55 | ); 56 | })} 57 | 58 | ); 59 | }; 60 | -------------------------------------------------------------------------------- /example/ios/OwlDemo/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | en 7 | CFBundleDisplayName 8 | OwlDemo 9 | CFBundleExecutable 10 | $(EXECUTABLE_NAME) 11 | CFBundleIdentifier 12 | $(PRODUCT_BUNDLE_IDENTIFIER) 13 | CFBundleInfoDictionaryVersion 14 | 6.0 15 | CFBundleName 16 | $(PRODUCT_NAME) 17 | CFBundlePackageType 18 | APPL 19 | CFBundleShortVersionString 20 | $(MARKETING_VERSION) 21 | CFBundleSignature 22 | ???? 23 | CFBundleVersion 24 | $(CURRENT_PROJECT_VERSION) 25 | LSRequiresIPhoneOS 26 | 27 | NSAppTransportSecurity 28 | 29 | NSExceptionDomains 30 | 31 | localhost 32 | 33 | NSExceptionAllowsInsecureHTTPLoads 34 | 35 | 36 | 37 | 38 | NSLocationWhenInUseUsageDescription 39 | 40 | UILaunchStoryboardName 41 | LaunchScreen 42 | UIRequiredDeviceCapabilities 43 | 44 | armv7 45 | 46 | UISupportedInterfaceOrientations 47 | 48 | UIInterfaceOrientationPortrait 49 | UIInterfaceOrientationLandscapeLeft 50 | UIInterfaceOrientationLandscapeRight 51 | 52 | UIViewControllerBasedStatusBarAppearance 53 | 54 | 55 | 56 | -------------------------------------------------------------------------------- /website/src/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Layout from '@theme/Layout'; 3 | 4 | import { Hero } from '../components/Hero'; 5 | import { HomepageFeatures } from '../components/HomepageFeatures'; 6 | 7 | const META_DESCRIPTION = 8 | 'React Native Owl is a visual regression testing library for React Native that enables developers to introduce visual regression tests to their apps for iOS and Android.'; 9 | 10 | export default function Home() { 11 | return ( 12 | 17 | 18 |
19 |

About

20 |

21 | This visual regression testing for React Native library enables 22 | developers to introduce visual regression tests to their apps for{' '} 23 | iOS and Android. Being heavily 24 | inspired by{' '} 25 | 30 | Detox 31 | 32 | , an end-to-end testing and automation framework, this library uses a 33 | similar API that makes setting up react-native-owl and running the 34 | tests locally and on your preferred CI service, seamless. 35 |

36 | 37 |

38 | Learn more about the background behind this library in{' '} 39 | 40 | the announcement on the Formidable Blog 41 | 42 | . 43 |

44 | 45 | 46 |
47 |
48 | ); 49 | } 50 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 6 | 7 | ### Description 8 | 9 | 10 | 11 | Fixes # (issue) 12 | 13 | #### Type of Change 14 | 15 | 16 | 17 | - [ ] Bug fix (non-breaking change which fixes an issue) 18 | - [ ] New feature (non-breaking change which adds functionality) 19 | - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) 20 | - [ ] Adding or updating CI (Actions) 21 | - [ ] This change requires a documentation update 22 | 23 | ### How Has This Been Tested? 24 | 25 | 26 | 27 | ### Checklist: (Feel free to delete this section upon completion) 28 | 29 | - [ ] I have performed a self-review of my own code 30 | - [ ] I have commented my code, particularly in hard-to-understand areas 31 | - [ ] I have made corresponding changes to the documentation 32 | - [ ] My code follows the style guidelines of this project (I have run `yarn prettier:apply`) 33 | - [ ] I have added tests that prove my fix is effective or that my feature works 34 | - [ ] New and existing unit tests pass locally with my changes (I have run `yarn test`) 35 | - [ ] My changes generate no new warnings 36 | - [ ] Any dependent changes have been merged and published in downstream modules 37 | 38 | ### Screenshots (for visual changes): 39 | -------------------------------------------------------------------------------- /native/android/build.gradle: -------------------------------------------------------------------------------- 1 | buildscript { 2 | if (project == rootProject) { 3 | repositories { 4 | google() 5 | mavenCentral() 6 | jcenter() 7 | } 8 | 9 | dependencies { 10 | classpath 'com.android.tools.build:gradle:3.5.3' 11 | } 12 | } 13 | } 14 | 15 | apply plugin: 'com.android.library' 16 | 17 | def safeExtGet(prop, fallback) { 18 | rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback 19 | } 20 | 21 | def isOwlBuild = project.hasProperty("isOwlBuild") 22 | 23 | android { 24 | compileSdkVersion safeExtGet('compileSdkVersion', 29) 25 | defaultConfig { 26 | minSdkVersion safeExtGet('minSdkVersion', 16) 27 | targetSdkVersion safeExtGet('targetSdkVersion', 29) 28 | versionCode 1 29 | versionName "1.0" 30 | } 31 | 32 | sourceSets { 33 | main { 34 | // If this build is triggered by react-native-owl, then include use AndroidManifest.owl.xml 35 | if (isOwlBuild) { 36 | manifest.srcFile 'src/main/AndroidManifest.owl.xml' 37 | } 38 | } 39 | } 40 | 41 | copy { 42 | from file(isOwlBuild ? "templates/ReactNativeOwlModuleOwl.java": "templates/ReactNativeOwlModule.java") 43 | into "src/main/java/com/formidable/reactnativeowl" 44 | rename { "ReactNativeOwlModule.java" } 45 | } 46 | 47 | buildTypes { 48 | release { 49 | minifyEnabled false 50 | } 51 | } 52 | lintOptions { 53 | disable 'GradleCompatible' 54 | } 55 | } 56 | 57 | repositories { 58 | mavenLocal() 59 | maven { 60 | // All of React Native (JS, Obj-C sources, Android binaries) is installed from npm 61 | url("$rootDir/../node_modules/react-native/android") 62 | } 63 | google() 64 | mavenCentral() 65 | jcenter() 66 | } 67 | 68 | dependencies { 69 | //noinspection GradleDynamicVersion 70 | implementation "com.facebook.react:react-native:+" // From node_modules 71 | } 72 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Language and Environment */ 4 | "target": "es5", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ 5 | "lib": ["es5"], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ 6 | 7 | /* Modules */ 8 | "module": "commonjs", /* Specify what module code is generated. */ 9 | "rootDir": "./lib", /* Specify the root folder within your source files. */ 10 | "resolveJsonModule": true, /* Enable importing .json files */ 11 | 12 | /* Emit */ 13 | "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ 14 | "outDir": "./dist", /* Specify an output folder for all emitted files. */ 15 | 16 | /* Interop Constraints */ 17 | "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables `allowSyntheticDefaultImports` for type compatibility. */ 18 | "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ 19 | 20 | /* Type Checking */ 21 | "strict": true, /* Enable all strict type-checking options. */ 22 | "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied `any` type.. */ 23 | "strictNullChecks": true, /* When type checking, take into account `null` and `undefined`. */ 24 | "noUnusedLocals": true, /* Enable error reporting when a local variables aren't read. */ 25 | 26 | "skipLibCheck": true, 27 | }, 28 | "include": ["lib/**/*", "lib/jest-config.json"], 29 | "exclude": ["node_modules", "dist"] 30 | } 31 | -------------------------------------------------------------------------------- /lib/cli/index.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import yargs, { Options } from 'yargs'; 3 | import { CliBuildOptions, CliRunOptions } from '../types'; 4 | const { hideBin } = require('yargs/helpers'); 5 | const argv = yargs(hideBin(process.argv)); 6 | 7 | import { buildHandler } from './build'; 8 | import { runHandler } from './run'; 9 | 10 | const plaformOption: Options = { 11 | alias: 'p', 12 | describe: 'Platform to build and run the app', 13 | demandOption: true, 14 | choices: ['ios', 'android'], 15 | }; 16 | 17 | const configOption: Options = { 18 | alias: 'c', 19 | describe: 'Configuration file to be used', 20 | type: 'string', 21 | default: './owl.config.json', 22 | }; 23 | 24 | const updateOption: Options = { 25 | alias: 'u', 26 | describe: 'Update the baseline screenshots', 27 | type: 'boolean', 28 | default: false, 29 | }; 30 | 31 | const testNamePattern: Options = { 32 | alias: 't', 33 | describe: 'Run only tests with a name that matches the regex', 34 | type: 'string', 35 | }; 36 | 37 | const testPathPatternOption: Options = { 38 | alias: 'tp', 39 | describe: 'Run Test for a matching path pattern', 40 | type: 'string', 41 | default: '', 42 | }; 43 | 44 | const builderOptionsRun = { 45 | config: configOption, 46 | platform: plaformOption, 47 | }; 48 | 49 | const builderOptionsTest = { 50 | config: configOption, 51 | platform: plaformOption, 52 | update: updateOption, 53 | testNamePattern: testNamePattern, 54 | testPathPattern: testPathPatternOption, 55 | }; 56 | 57 | argv 58 | .usage('Usage: $0 [options]') 59 | .command({ 60 | command: 'build', 61 | describe: 'Build the React Native project', 62 | builder: builderOptionsRun, 63 | handler: buildHandler, 64 | }) 65 | .command({ 66 | command: 'test', 67 | describe: 'Runs the test suite', 68 | builder: builderOptionsTest, 69 | handler: runHandler, 70 | }) 71 | .help('help') 72 | .alias('h', 'help') 73 | .showHelpOnFail(false, 'Specify --help for available options') 74 | .alias('v', 'version').argv; 75 | -------------------------------------------------------------------------------- /example/android/app/src/main/res/drawable/rn_edit_text_material.xml: -------------------------------------------------------------------------------- 1 | 2 | 16 | 21 | 22 | 23 | 32 | 33 | 34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /example/android/gradle.properties: -------------------------------------------------------------------------------- 1 | # Project-wide Gradle settings. 2 | 3 | # IDE (e.g. Android Studio) users: 4 | # Gradle settings configured through the IDE *will override* 5 | # any settings specified in this file. 6 | 7 | # For more details on how to configure your build environment visit 8 | # http://www.gradle.org/docs/current/userguide/build_environment.html 9 | 10 | # Specifies the JVM arguments used for the daemon process. 11 | # The setting is particularly useful for tweaking memory settings. 12 | # Default value: -Xmx512m -XX:MaxMetaspaceSize=256m 13 | org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m 14 | 15 | # When configured, Gradle will run in incubating parallel mode. 16 | # This option should only be used with decoupled projects. More details, visit 17 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects 18 | # org.gradle.parallel=true 19 | 20 | # AndroidX package structure to make it clearer which packages are bundled with the 21 | # Android operating system, and which are packaged with your app's APK 22 | # https://developer.android.com/topic/libraries/support-library/androidx-rn 23 | android.useAndroidX=true 24 | # Automatically convert third-party libraries to use AndroidX 25 | android.enableJetifier=true 26 | 27 | # Version of flipper SDK to use with React Native 28 | FLIPPER_VERSION=0.125.0 29 | 30 | # Use this property to specify which architecture you want to build. 31 | # You can also override it from the CLI using 32 | # ./gradlew -PreactNativeArchitectures=x86_64 33 | reactNativeArchitectures=armeabi-v7a,arm64-v8a,x86,x86_64 34 | 35 | # Use this property to enable support to the new architecture. 36 | # This will allow you to use TurboModules and the Fabric render in 37 | # your application. You should enable this flag either if you want 38 | # to write custom TurboModules/Fabric components OR use libraries that 39 | # are providing them. 40 | newArchEnabled=false 41 | 42 | # Use this property to enable or disable the Hermes JS engine. 43 | # If set to false, you will be using JSC instead. 44 | hermesEnabled=true 45 | -------------------------------------------------------------------------------- /example/README.md: -------------------------------------------------------------------------------- 1 | # react-native-owl-demo 2 | 3 | > A simple react-native app that uses _react-native-owl_ with examples. Also used for the development of the library. 4 | 5 | ## Running the app 6 | 7 | First, install the dependencies: 8 | 9 | ```sh 10 | yarn install 11 | ``` 12 | 13 | Then run the metro bundler: 14 | 15 | ```sh 16 | yarn start 17 | ``` 18 | 19 | ### iOS 20 | 21 | Install the pods: 22 | 23 | ```sh 24 | cd ios/ && pod install && cd .. 25 | ``` 26 | 27 | Then run the app: 28 | 29 | ```sh 30 | yarn ios 31 | ``` 32 | 33 | ### Android 34 | 35 | Run the app: 36 | 37 | ```sh 38 | yarn android 39 | ``` 40 | 41 | ## Development 42 | 43 | To use a local version of react-native-owl, first, navigate to the top level of the repository (one level up from the current directory) and run the following commands: 44 | 45 | ```sh 46 | # Assuming you are inside react-native-owl - ie. ~/Projects/react-native-owl 47 | yarn build # or watch 48 | ``` 49 | 50 | Now, the _example_ app will be using the local version of react-native-owl. 51 | 52 | ## Scripts 53 | 54 | As seen in `package.json`. 55 | 56 | | Name | Description | 57 | | ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------- | 58 | | `yarn owl:build:ios` | Builds the app for iOS. A wrapper around `xcodebuild`. | 59 | | `yarn owl:test:ios` | Runs the tests, takes a screenshot and compares the images. If passed the `--update` argument, it generates fresh baseline screenshots. | 60 | | `yarn owl:build:android` | Builds the app for Android. A wrapper around the `gradle` build command. | 61 | | `yarn owl:test:android` | Runs the tests, takes a screenshot and compares the images. If passed the `--update` argument, it generates fresh baseline screenshots. | 62 | -------------------------------------------------------------------------------- /example/android/app/src/main/java/com/owldemo/MainApplication.java: -------------------------------------------------------------------------------- 1 | package com.owldemo; 2 | 3 | import android.app.Application; 4 | import com.facebook.react.PackageList; 5 | import com.facebook.react.ReactApplication; 6 | import com.facebook.react.ReactNativeHost; 7 | import com.facebook.react.ReactPackage; 8 | import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint; 9 | import com.facebook.react.defaults.DefaultReactNativeHost; 10 | import com.facebook.soloader.SoLoader; 11 | import java.util.List; 12 | 13 | public class MainApplication extends Application implements ReactApplication { 14 | 15 | private final ReactNativeHost mReactNativeHost = 16 | new DefaultReactNativeHost(this) { 17 | @Override 18 | public boolean getUseDeveloperSupport() { 19 | return BuildConfig.DEBUG; 20 | } 21 | 22 | @Override 23 | protected List getPackages() { 24 | @SuppressWarnings("UnnecessaryLocalVariable") 25 | List packages = new PackageList(this).getPackages(); 26 | // Packages that cannot be autolinked yet can be added manually here, for example: 27 | // packages.add(new MyReactNativePackage()); 28 | return packages; 29 | } 30 | 31 | @Override 32 | protected String getJSMainModuleName() { 33 | return "index"; 34 | } 35 | 36 | @Override 37 | protected boolean isNewArchEnabled() { 38 | return BuildConfig.IS_NEW_ARCHITECTURE_ENABLED; 39 | } 40 | 41 | @Override 42 | protected Boolean isHermesEnabled() { 43 | return BuildConfig.IS_HERMES_ENABLED; 44 | } 45 | }; 46 | 47 | @Override 48 | public ReactNativeHost getReactNativeHost() { 49 | return mReactNativeHost; 50 | } 51 | 52 | @Override 53 | public void onCreate() { 54 | super.onCreate(); 55 | SoLoader.init(this, /* native exopackage */ false); 56 | if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) { 57 | // If you opted-in for the New Architecture, we load the native entry point for this app. 58 | DefaultNewArchitectureEntryPoint.load(); 59 | } 60 | ReactNativeFlipper.initializeFlipper(this, getReactNativeHost().getReactInstanceManager()); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /example/ios/OwlDemoTests/OwlDemoTests.m: -------------------------------------------------------------------------------- 1 | #import 2 | #import 3 | 4 | #import 5 | #import 6 | 7 | #define TIMEOUT_SECONDS 600 8 | #define TEXT_TO_LOOK_FOR @"Welcome to React" 9 | 10 | @interface OwlDemoTests : XCTestCase 11 | 12 | @end 13 | 14 | @implementation OwlDemoTests 15 | 16 | - (BOOL)findSubviewInView:(UIView *)view matching:(BOOL (^)(UIView *view))test 17 | { 18 | if (test(view)) { 19 | return YES; 20 | } 21 | for (UIView *subview in [view subviews]) { 22 | if ([self findSubviewInView:subview matching:test]) { 23 | return YES; 24 | } 25 | } 26 | return NO; 27 | } 28 | 29 | - (void)testRendersWelcomeScreen 30 | { 31 | UIViewController *vc = [[[RCTSharedApplication() delegate] window] rootViewController]; 32 | NSDate *date = [NSDate dateWithTimeIntervalSinceNow:TIMEOUT_SECONDS]; 33 | BOOL foundElement = NO; 34 | 35 | __block NSString *redboxError = nil; 36 | #ifdef DEBUG 37 | RCTSetLogFunction( 38 | ^(RCTLogLevel level, RCTLogSource source, NSString *fileName, NSNumber *lineNumber, NSString *message) { 39 | if (level >= RCTLogLevelError) { 40 | redboxError = message; 41 | } 42 | }); 43 | #endif 44 | 45 | while ([date timeIntervalSinceNow] > 0 && !foundElement && !redboxError) { 46 | [[NSRunLoop mainRunLoop] runMode:NSDefaultRunLoopMode beforeDate:[NSDate dateWithTimeIntervalSinceNow:0.1]]; 47 | [[NSRunLoop mainRunLoop] runMode:NSRunLoopCommonModes beforeDate:[NSDate dateWithTimeIntervalSinceNow:0.1]]; 48 | 49 | foundElement = [self findSubviewInView:vc.view 50 | matching:^BOOL(UIView *view) { 51 | if ([view.accessibilityLabel isEqualToString:TEXT_TO_LOOK_FOR]) { 52 | return YES; 53 | } 54 | return NO; 55 | }]; 56 | } 57 | 58 | #ifdef DEBUG 59 | RCTSetLogFunction(RCTDefaultLogFunction); 60 | #endif 61 | 62 | XCTAssertNil(redboxError, @"RedBox error: %@", redboxError); 63 | XCTAssertTrue(foundElement, @"Couldn't find element with text '%@' in %d seconds", TEXT_TO_LOOK_FOR, TIMEOUT_SECONDS); 64 | } 65 | 66 | @end 67 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-native-owl", 3 | "version": "1.5.0", 4 | "description": "Visual regression testing for React Native", 5 | "main": "./dist/index.js", 6 | "types": "./dist/index.d.ts", 7 | "bin": { 8 | "owl": "./dist/cli/index.js" 9 | }, 10 | "files": [ 11 | "dist/", 12 | "!dist/**/*.test.*", 13 | "native/", 14 | "scripts/", 15 | "react-native.config.js", 16 | "README.md" 17 | ], 18 | "scripts": { 19 | "prepare:report": "mkdir -p dist/report && cp lib/report/index.html dist/report/", 20 | "prebuild": "yarn prepare:report", 21 | "build": "tsc", 22 | "prewatch": "yarn prepare:report", 23 | "watch": "tsc --watch", 24 | "prettier:check": "prettier --check 'lib/**/*.{js,ts,tsx}'", 25 | "prettier:apply": "prettier --write 'lib/**/*.{js,ts,tsx}'", 26 | "test": "yarn jest" 27 | }, 28 | "repository": "https://github.com/FormidableLabs/react-native-owl", 29 | "author": "Emmanouil Konstantinidis ", 30 | "license": "MIT", 31 | "keywords": [ 32 | "react-native", 33 | "ios", 34 | "android", 35 | "mobile", 36 | "visual regression", 37 | "testing", 38 | "tooling" 39 | ], 40 | "jest": { 41 | "preset": "ts-jest", 42 | "testEnvironment": "node", 43 | "testMatch": [ 44 | "**/lib/**/*.test.[jt]s" 45 | ] 46 | }, 47 | "dependencies": { 48 | "ajv": "^7.0.3", 49 | "execa": "^5.1.1", 50 | "handlebars": "^4.7.7", 51 | "pixelmatch": "^5.2.1", 52 | "pngjs": "^6.0.0", 53 | "ws": "^8.2.3", 54 | "yargs": "^17.2.1" 55 | }, 56 | "peerDependencies": { 57 | "react": "^17 || ^18", 58 | "react-native": "^0" 59 | }, 60 | "devDependencies": { 61 | "@changesets/cli": "^2.26.1", 62 | "@svitejs/changesets-changelog-github-compact": "^0.1.1", 63 | "@types/jest": "^26.0.19", 64 | "@types/pixelmatch": "^5.2.4", 65 | "@types/pngjs": "^6.0.1", 66 | "@types/react": "^18.0.20", 67 | "@types/react-native": "^0.65.x", 68 | "@types/ws": "^8.2.0", 69 | "@types/yargs": "^17.0.5", 70 | "jest": "^26.6.3", 71 | "jest-websocket-mock": "^2.3.0", 72 | "prettier": "^2.4.1", 73 | "react": "^18.1.0", 74 | "react-native": "^0.68.2", 75 | "ts-jest": "^26.4.4", 76 | "typescript": "^4.4.4" 77 | }, 78 | "publishConfig": { 79 | "provenance": true 80 | } 81 | } -------------------------------------------------------------------------------- /ExpoExample/__tests__/App.owl.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | takeScreenshot, 3 | press, 4 | toExist, 5 | changeText, 6 | scrollTo, 7 | scrollToEnd, 8 | longPress, 9 | reload, 10 | } from 'react-native-owl'; 11 | 12 | jest.setTimeout(30000); 13 | 14 | describe('App.tsx', () => { 15 | describe('Basic navigation', () => { 16 | it('takes a screenshot of the initial screen', async () => { 17 | const screen = await takeScreenshot('initial'); 18 | 19 | expect(screen).toMatchBaseline(); 20 | }); 21 | 22 | it('longPress a Pressable, then takes a screenshot', async () => { 23 | await longPress('Pressable'); 24 | 25 | const screen = await takeScreenshot('long-press'); 26 | 27 | expect(screen).toMatchBaseline(); 28 | }); 29 | 30 | it('press a Pressable, waits for an element then takes a screenshot', async () => { 31 | await press('Pressable'); 32 | 33 | await toExist('TextInput'); 34 | 35 | const screen = await takeScreenshot('test-input'); 36 | 37 | expect(screen).toMatchBaseline(); 38 | }); 39 | 40 | it('enters some text and takes a screenshot', async () => { 41 | await changeText('TextInput', 'Entered text'); 42 | 43 | const screen = await takeScreenshot('entered-text'); 44 | 45 | expect(screen).toMatchBaseline(); 46 | }); 47 | 48 | it('scrolls a bit and takes a screenshot', async () => { 49 | await scrollTo('ScrollView', { y: 50 }); 50 | 51 | const screen = await takeScreenshot('scroll-to'); 52 | 53 | expect(screen).toMatchBaseline(); 54 | }); 55 | 56 | it('scrolls to end and takes a screenshot', async () => { 57 | await scrollToEnd('ScrollView'); 58 | 59 | const screen = await takeScreenshot('scroll-to-end'); 60 | 61 | expect(screen).toMatchBaseline(); 62 | }); 63 | 64 | it('takes a screenshot with a custom threshold', async () => { 65 | const screen = await takeScreenshot('custom-threshold'); 66 | 67 | expect(screen).toMatchBaseline({threshold: 0.25}); 68 | }); 69 | }); 70 | 71 | describe('Reload example', () => { 72 | beforeAll(async () => { 73 | await reload(); 74 | }); 75 | 76 | it('takes a screenshot of the welcome screen', async () => { 77 | const screen = await takeScreenshot('after-reload'); 78 | 79 | expect(screen).toMatchBaseline(); 80 | }); 81 | }); 82 | }); 83 | -------------------------------------------------------------------------------- /example/__tests__/App.owl.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | takeScreenshot, 3 | press, 4 | toExist, 5 | changeText, 6 | scrollTo, 7 | scrollToEnd, 8 | longPress, 9 | reload, 10 | } from 'react-native-owl'; 11 | 12 | jest.setTimeout(30000); 13 | 14 | describe('App.tsx', () => { 15 | describe('Basic navigation', () => { 16 | it('takes a screenshot of the initial screen', async () => { 17 | const screen = await takeScreenshot('initial'); 18 | 19 | expect(screen).toMatchBaseline(); 20 | }); 21 | 22 | it('longPress a Pressable, then takes a screenshot', async () => { 23 | await longPress('Pressable'); 24 | 25 | const screen = await takeScreenshot('long-press'); 26 | 27 | expect(screen).toMatchBaseline(); 28 | }); 29 | 30 | it('press a Pressable, waits for an element then takes a screenshot', async () => { 31 | await press('Pressable'); 32 | 33 | await toExist('TextInput'); 34 | 35 | const screen = await takeScreenshot('test-input'); 36 | 37 | expect(screen).toMatchBaseline(); 38 | }); 39 | 40 | it('enters some text and takes a screenshot', async () => { 41 | await changeText('TextInput', 'Entered text'); 42 | 43 | const screen = await takeScreenshot('entered-text'); 44 | 45 | expect(screen).toMatchBaseline(); 46 | }); 47 | 48 | it('scrolls a bit and takes a screenshot', async () => { 49 | await scrollTo('ScrollView', {y: 50}); 50 | 51 | const screen = await takeScreenshot('scroll-to'); 52 | 53 | expect(screen).toMatchBaseline(); 54 | }); 55 | 56 | it('scrolls to end and takes a screenshot', async () => { 57 | await scrollToEnd('ScrollView'); 58 | 59 | const screen = await takeScreenshot('scroll-to-end'); 60 | 61 | expect(screen).toMatchBaseline(); 62 | }); 63 | 64 | it('takes a screenshot with a custom threshold', async () => { 65 | const screen = await takeScreenshot('custom-threshold'); 66 | 67 | expect(screen).toMatchBaseline({threshold: 0.25}); 68 | }); 69 | }); 70 | 71 | describe('Reload example', () => { 72 | beforeAll(async () => { 73 | await reload(); 74 | }); 75 | 76 | it('takes a screenshot of the welcome screen', async () => { 77 | const screen = await takeScreenshot('after-reload'); 78 | 79 | expect(screen).toMatchBaseline(); 80 | }, 100000); 81 | }); 82 | }); 83 | -------------------------------------------------------------------------------- /lib/logger.test.ts: -------------------------------------------------------------------------------- 1 | import { Logger } from './logger'; 2 | 3 | describe('logger.ts', () => { 4 | const logMessage = 'Hello World'; 5 | 6 | const logInfoMock = jest.spyOn(global.console, 'info'); 7 | const logWarnMock = jest.spyOn(global.console, 'warn'); 8 | const logErrorMock = jest.spyOn(global.console, 'error'); 9 | const logPrintMock = jest.spyOn(global.console, 'log'); 10 | 11 | beforeEach(() => { 12 | logInfoMock.mockReset(); 13 | logWarnMock.mockReset(); 14 | logErrorMock.mockReset(); 15 | logPrintMock.mockReset(); 16 | }); 17 | 18 | describe('info', () => { 19 | it('should log a message', () => { 20 | const logger = new Logger(true); 21 | logger.info(logMessage); 22 | expect(logInfoMock).toHaveBeenCalledWith(logMessage); 23 | }); 24 | 25 | it('should not log a message when disabled', () => { 26 | const logger = new Logger(false); 27 | logger.info(logMessage); 28 | expect(logInfoMock).not.toHaveBeenCalled(); 29 | }); 30 | }); 31 | 32 | describe('warn', () => { 33 | it('should log a message', () => { 34 | const logger = new Logger(true); 35 | logger.warn(logMessage); 36 | expect(logWarnMock).toHaveBeenCalledWith(logMessage); 37 | }); 38 | 39 | it('should not log a message when disabled', () => { 40 | const logger = new Logger(false); 41 | logger.warn(logMessage); 42 | expect(logWarnMock).not.toHaveBeenCalled(); 43 | }); 44 | }); 45 | 46 | describe('error', () => { 47 | it('should log a message', () => { 48 | const logger = new Logger(true); 49 | logger.error(logMessage); 50 | expect(logErrorMock).toHaveBeenCalledWith(logMessage); 51 | }); 52 | 53 | it('should not log a message when disabled', () => { 54 | const logger = new Logger(false); 55 | logger.error(logMessage); 56 | expect(logErrorMock).not.toHaveBeenCalled(); 57 | }); 58 | }); 59 | 60 | describe('print', () => { 61 | it('should log a message', () => { 62 | const logger = new Logger(true); 63 | logger.print(logMessage); 64 | expect(logPrintMock).toHaveBeenCalledWith(logMessage); 65 | }); 66 | 67 | it('should still log a message when disabled', () => { 68 | const logger = new Logger(false); 69 | logger.print(logMessage); 70 | expect(logPrintMock).toHaveBeenCalledWith(logMessage); 71 | }); 72 | }); 73 | }); 74 | -------------------------------------------------------------------------------- /example/ios/Podfile: -------------------------------------------------------------------------------- 1 | require_relative '../node_modules/react-native/scripts/react_native_pods' 2 | require_relative '../node_modules/@react-native-community/cli-platform-ios/native_modules' 3 | 4 | platform :ios, min_ios_version_supported 5 | prepare_react_native_project! 6 | 7 | # If you are using a `react-native-flipper` your iOS build will fail when `NO_FLIPPER=1` is set. 8 | # because `react-native-flipper` depends on (FlipperKit,...) that will be excluded 9 | # 10 | # To fix this you can also exclude `react-native-flipper` using a `react-native.config.js` 11 | # ```js 12 | # module.exports = { 13 | # dependencies: { 14 | # ...(process.env.NO_FLIPPER ? { 'react-native-flipper': { platforms: { ios: null } } } : {}), 15 | # ``` 16 | # flipper_config = ENV['NO_FLIPPER'] == "1" ? FlipperConfiguration.disabled : FlipperConfiguration.enabled 17 | flipper_config = FlipperConfiguration.disabled 18 | 19 | linkage = ENV['USE_FRAMEWORKS'] 20 | if linkage != nil 21 | Pod::UI.puts "Configuring Pod with #{linkage}ally linked Frameworks".green 22 | use_frameworks! :linkage => linkage.to_sym 23 | end 24 | 25 | target 'OwlDemo' do 26 | config = use_native_modules! 27 | 28 | # Flags change depending on the env values. 29 | flags = get_default_flags() 30 | 31 | use_react_native!( 32 | :path => config[:reactNativePath], 33 | # Hermes is now enabled by default. Disable by setting this flag to false. 34 | # Upcoming versions of React Native may rely on get_default_flags(), but 35 | # we make it explicit here to aid in the React Native upgrade process. 36 | :hermes_enabled => flags[:hermes_enabled], 37 | :fabric_enabled => flags[:fabric_enabled], 38 | # Enables Flipper. 39 | # 40 | # Note that if you have use_frameworks! enabled, Flipper will not work and 41 | # you should disable the next line. 42 | :flipper_configuration => flipper_config, 43 | # An absolute path to your application root. 44 | :app_path => "#{Pod::Config.instance.installation_root}/.." 45 | ) 46 | 47 | target 'OwlDemoTests' do 48 | inherit! :complete 49 | # Pods for testing 50 | end 51 | 52 | post_install do |installer| 53 | react_native_post_install( 54 | installer, 55 | # Set `mac_catalyst_enabled` to `true` in order to apply patches 56 | # necessary for Mac Catalyst builds 57 | :mac_catalyst_enabled => false 58 | ) 59 | __apply_Xcode_12_5_M1_post_install_workaround(installer) 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /lib/client/client.test.ts: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | describe('client.ts', () => { 4 | jest.mock('react-native', () => ({ 5 | Platform: { 6 | OS: 'android', 7 | }, 8 | })); 9 | 10 | afterEach(() => { 11 | jest.restoreAllMocks(); 12 | }); 13 | 14 | const client = require('./client'); 15 | 16 | it('inits the client', () => { 17 | const patchReact = jest.fn(); 18 | const waitForWebSocket = jest.fn(); 19 | 20 | jest.spyOn(client, 'patchReact').mockImplementation(patchReact); 21 | jest.spyOn(client, 'waitForWebSocket').mockImplementation(waitForWebSocket); 22 | 23 | client.initClient(); 24 | 25 | expect(patchReact).toHaveBeenCalled(); 26 | expect(waitForWebSocket).toHaveBeenCalled(); 27 | }); 28 | 29 | it('patches react', () => { 30 | const createElement = jest 31 | .spyOn(React, 'createElement') 32 | .mockImplementation(); 33 | 34 | const applyElementTracking = jest.fn(); 35 | 36 | jest 37 | .spyOn(client, 'applyElementTracking') 38 | .mockImplementation(applyElementTracking); 39 | 40 | client.patchReact(); 41 | 42 | const props = { testID: 'testID' }; 43 | React.createElement('View', props); 44 | 45 | expect(createElement).toHaveBeenCalledTimes(1); 46 | expect(applyElementTracking).toHaveBeenCalledWith(props); 47 | }); 48 | 49 | describe('applyElementTracking', () => { 50 | const add = jest.fn(); 51 | 52 | beforeEach(() => { 53 | const trackedElements = require('./trackedElements'); 54 | 55 | add.mockReset(); 56 | 57 | jest.spyOn(trackedElements, 'add').mockImplementation(add); 58 | }); 59 | 60 | it('tracks elements with a testID', () => { 61 | const newProps = client.applyElementTracking({ 62 | testID: 'testID', 63 | foo: 'bar', 64 | }); 65 | 66 | expect(add).toHaveBeenCalledTimes(1); 67 | expect(newProps).toEqual({ 68 | testID: 'testID', 69 | foo: 'bar', 70 | ref: { current: null }, 71 | showsHorizontalScrollIndicator: false, 72 | showsVerticalScrollIndicator: false, 73 | }); 74 | }); 75 | 76 | it('does not track elements without a testID', () => { 77 | const newProps = client.applyElementTracking({ 78 | testID: undefined, 79 | foo: 'bar', 80 | }); 81 | 82 | expect(add).toHaveBeenCalledTimes(0); 83 | expect(newProps).toEqual({ 84 | foo: 'bar', 85 | showsHorizontalScrollIndicator: false, 86 | showsVerticalScrollIndicator: false, 87 | }); 88 | }); 89 | }); 90 | }); 91 | -------------------------------------------------------------------------------- /lib/websocket.test.ts: -------------------------------------------------------------------------------- 1 | import WebSocket from 'ws'; 2 | 3 | import { createWebSocketClient, startWebSocketServer } from './websocket'; 4 | import { Logger } from './logger'; 5 | import { waitFor } from './utils/wait-for'; 6 | 7 | describe('websocket.ts', () => { 8 | let wsServer: WebSocket.Server; 9 | let wsClient1: WebSocket; 10 | let wsClient2: WebSocket; 11 | 12 | const serverLogger = new Logger(); 13 | const client1Logger = new Logger(); 14 | const client2Logger = new Logger(); 15 | 16 | const mockServerLoggerInfo = jest.spyOn(serverLogger, 'info'); 17 | const mockClient1LoggerInfo = jest.spyOn(client1Logger, 'info'); 18 | const mockClient2LoggerInfo = jest.spyOn(client2Logger, 'info'); 19 | 20 | const mockOnMessage = jest.fn(() => {}); 21 | 22 | beforeEach(async () => { 23 | mockServerLoggerInfo.mockReset(); 24 | mockClient1LoggerInfo.mockReset(); 25 | mockClient2LoggerInfo.mockReset(); 26 | mockOnMessage.mockReset(); 27 | 28 | wsServer = await startWebSocketServer(serverLogger); 29 | wsClient1 = await createWebSocketClient(client1Logger, mockOnMessage); 30 | wsClient2 = await createWebSocketClient(client2Logger, mockOnMessage); 31 | }); 32 | 33 | afterEach(() => { 34 | wsServer.close(); 35 | wsClient1.close(); 36 | wsClient2.close(); 37 | }); 38 | 39 | it('should start the server and accept client connections', async () => { 40 | await waitFor(5); 41 | 42 | expect(mockServerLoggerInfo).toHaveBeenNthCalledWith( 43 | 1, 44 | '[OWL - WebSocket] Listening on port 8123.' 45 | ); 46 | }); 47 | 48 | it('should forward messages to other clients', async () => { 49 | await wsClient1.send('Hello!'); 50 | 51 | await waitFor(5); 52 | 53 | // We are just checking that client1 did not receive the message, 54 | // and that client2 did. 55 | // We are not concerned with the order of the logger calls. 56 | expect( 57 | mockClient1LoggerInfo.mock.calls.some( 58 | (call) => 59 | call[0] === '[OWL - WebSocket] The client received a message: Hello!.' 60 | ) 61 | ).toBeFalsy(); 62 | 63 | expect( 64 | mockClient2LoggerInfo.mock.calls.some( 65 | (call) => 66 | call[0] === '[OWL - WebSocket] The client received a message: Hello!.' 67 | ) 68 | ).toBeTruthy(); 69 | }); 70 | 71 | it('should use the onMessage handler', async () => { 72 | await wsClient1.send('Hello!'); 73 | 74 | await waitFor(5); 75 | 76 | // Check that the onMessage callback was used 77 | expect(mockOnMessage).toHaveBeenCalledTimes(1); 78 | expect(mockOnMessage).toHaveBeenCalledWith('Hello!'); 79 | }); 80 | }); 81 | -------------------------------------------------------------------------------- /lib/report.test.ts: -------------------------------------------------------------------------------- 1 | import process from 'process'; 2 | import handlebars from 'handlebars'; 3 | import { promises as fs } from 'fs'; 4 | import * as fileExists from './utils/file-exists'; 5 | 6 | import { Logger } from './logger'; 7 | import { generateReport } from './report'; 8 | 9 | describe('report.ts', () => { 10 | const logger = new Logger(); 11 | 12 | const htmlTemplate = '

Hello World

'; 13 | 14 | const readdirMock = jest.spyOn(fs, 'readdir'); 15 | const mkdirMock = jest.spyOn(fs, 'mkdir'); 16 | 17 | const readFileMock = jest.spyOn(fs, 'readFile'); 18 | const writeFileMock = jest.spyOn(fs, 'writeFile'); 19 | 20 | const cwdMock = jest 21 | .spyOn(process, 'cwd') 22 | .mockReturnValue('/Users/johndoe/Projects/my-project'); 23 | 24 | beforeEach(() => { 25 | writeFileMock.mockReset(); 26 | }); 27 | 28 | afterEach(() => { 29 | cwdMock.mockRestore(); 30 | jest.resetAllMocks(); 31 | }); 32 | 33 | it('should get the screenshots and create the html report', async () => { 34 | jest.spyOn(fileExists, 'fileExists').mockResolvedValue(true); 35 | const handlebarsCompileMock = jest 36 | .spyOn(handlebars, 'compile') 37 | .mockImplementationOnce(() => () => '

Hello World Compiled

'); 38 | 39 | readFileMock 40 | .mockResolvedValueOnce('{}') 41 | .mockResolvedValueOnce(htmlTemplate); 42 | mkdirMock.mockResolvedValue(undefined); 43 | readdirMock.mockResolvedValue([]); 44 | 45 | await generateReport(logger, 'ios'); 46 | 47 | expect(readdirMock).toHaveBeenCalledWith( 48 | '/Users/johndoe/Projects/my-project/.owl/diff/ios' 49 | ); 50 | expect(readdirMock).toHaveBeenCalledWith( 51 | '/Users/johndoe/Projects/my-project/.owl/baseline/ios' 52 | ); 53 | expect(handlebarsCompileMock).toHaveBeenCalledTimes(1); 54 | expect(writeFileMock).toHaveBeenCalledWith( 55 | '/Users/johndoe/Projects/my-project/.owl/report/index.html', 56 | '

Hello World Compiled

' 57 | ); 58 | }); 59 | 60 | it('should not generate the report if there is no baseline screenshots directory', async () => { 61 | jest.spyOn(fileExists, 'fileExists').mockResolvedValue(false); 62 | const handlebarsCompileMock = jest 63 | .spyOn(handlebars, 'compile') 64 | .mockImplementationOnce(() => () => '

Hello World Compiled

'); 65 | 66 | readFileMock 67 | .mockResolvedValueOnce('{}') 68 | .mockResolvedValueOnce(htmlTemplate); 69 | mkdirMock.mockResolvedValue(undefined); 70 | readdirMock.mockResolvedValue([]); 71 | 72 | await generateReport(logger, 'ios'); 73 | 74 | expect(readdirMock).not.toHaveBeenCalled(); 75 | 76 | expect(readdirMock).not.toHaveBeenCalled(); 77 | expect(handlebarsCompileMock).toHaveBeenCalledTimes(0); 78 | expect(writeFileMock).not.toHaveBeenCalled(); 79 | }); 80 | }); 81 | -------------------------------------------------------------------------------- /lib/matchers.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | import pixelmatch from 'pixelmatch'; 4 | import { PNG } from 'pngjs'; 5 | 6 | import { Platform } from './types'; 7 | 8 | declare global { 9 | namespace jest { 10 | interface Matchers { 11 | /** Compares the image passed to the baseline one */ 12 | toMatchBaseline: ({ 13 | threshold, 14 | }?: { 15 | threshold?: number; 16 | }) => CustomMatcherResult; 17 | } 18 | } 19 | } 20 | 21 | export const toMatchBaseline = ( 22 | latestPath: string, 23 | options: { threshold?: number } = { threshold: 0.1 } 24 | ) => { 25 | const platform = process.env.OWL_PLATFORM as Platform; 26 | const screenshotsDir = path.join(path.dirname(latestPath), '..', '..'); 27 | const baselinePath = path.join( 28 | screenshotsDir, 29 | 'baseline', 30 | platform, 31 | path.basename(latestPath) 32 | ); 33 | 34 | if (latestPath === baselinePath) { 35 | return { 36 | message: () => 'Generated a fresh baseline, skipping comparison.', 37 | pass: true, 38 | }; 39 | } 40 | 41 | try { 42 | const diffPath = path.join( 43 | screenshotsDir, 44 | 'diff', 45 | platform, 46 | path.basename(latestPath) 47 | ); 48 | fs.mkdirSync(path.dirname(diffPath), { recursive: true }); 49 | 50 | const baselineData = fs.readFileSync(baselinePath); 51 | const baselineImage = PNG.sync.read(baselineData); 52 | 53 | const latestData = fs.readFileSync(latestPath); 54 | const latestImage = PNG.sync.read(latestData); 55 | 56 | const diffImage = new PNG({ 57 | width: baselineImage.width, 58 | height: baselineImage.height, 59 | }); 60 | 61 | const diffPixelsCount = pixelmatch( 62 | baselineImage.data, 63 | latestImage.data, 64 | diffImage.data, 65 | baselineImage.width, 66 | baselineImage.height, 67 | { threshold: options?.threshold } 68 | ); 69 | 70 | if (diffPixelsCount === 0) { 71 | return { 72 | message: () => 73 | `Compared screenshot to match baseline. No differences were found.`, 74 | pass: true, 75 | }; 76 | } 77 | 78 | // Create and save the diff image 79 | fs.writeFileSync(diffPath, PNG.sync.write(diffImage)); 80 | 81 | return { 82 | message: () => 83 | `Compared screenshot to match baseline. ${diffPixelsCount} were different.`, 84 | pass: diffPixelsCount === 0, 85 | }; 86 | } catch (error) { 87 | let message = 'Unknown error'; 88 | if (error instanceof Error) { 89 | message = error.message; 90 | } 91 | 92 | return { 93 | message: () => `Screenshot diffing error - ${message}`, 94 | pass: false, 95 | }; 96 | } 97 | }; 98 | 99 | expect.extend({ toMatchBaseline }); 100 | -------------------------------------------------------------------------------- /lib/cli/build.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import execa from 'execa'; 3 | 4 | import { CliBuildOptions, Config } from '../types'; 5 | import { Logger } from '../logger'; 6 | import { getConfig } from './config'; 7 | 8 | export const ENTRY_FILE = 9 | './node_modules/react-native-owl/dist/client/index.app.js'; 10 | 11 | export const buildIOS = async ( 12 | config: Config, 13 | logger: Logger 14 | ): Promise => { 15 | const buildCommand = config.ios?.buildCommand 16 | ? [config.ios?.buildCommand] 17 | : [ 18 | `xcodebuild`, 19 | `-workspace ${config.ios?.workspace}`, 20 | `-scheme ${config.ios?.scheme?.split(' ').join('\\ ')}`, 21 | `-configuration ${config.ios?.configuration}`, 22 | `-sdk iphonesimulator`, 23 | `-derivedDataPath ios/build`, 24 | ]; 25 | 26 | if (!config.ios?.buildCommand && config.ios?.quiet) { 27 | buildCommand.push('-quiet'); 28 | } 29 | 30 | logger.info(`[OWL - CLI] Building the app with: ${buildCommand.join(' ')}.`); 31 | 32 | await execa.command(buildCommand.join(' '), { 33 | stdio: 'inherit', 34 | env: { 35 | ENTRY_FILE, 36 | }, 37 | }); 38 | }; 39 | 40 | export const buildAndroid = async ( 41 | config: Config, 42 | logger: Logger 43 | ): Promise => { 44 | const buildCommand = config.android?.buildCommand 45 | ? [config.android?.buildCommand] 46 | : [ 47 | `./gradlew`, 48 | config.android?.buildType === 'Debug' 49 | ? `assembleDebug` 50 | : 'assembleRelease', 51 | '--console plain', 52 | ]; 53 | 54 | if (!config.android?.buildCommand && config.android?.quiet) { 55 | buildCommand.push('--quiet'); 56 | } 57 | 58 | // Add a project environmental to tell build.gradle to use a specific Android Manifest that allows WebSocket usage. 59 | // (https://docs.gradle.org/current/userguide/command_line_interface.html#sec:environment_options) 60 | buildCommand.push('-PisOwlBuild=true'); 61 | 62 | const cwd = config.android?.buildCommand 63 | ? undefined 64 | : path.join(process.cwd(), 'android'); 65 | 66 | logger.info(`[OWL - CLI] Building the app with: ${buildCommand.join(' ')}.`); 67 | 68 | await execa.command(buildCommand.join(' '), { 69 | stdio: 'inherit', 70 | cwd, 71 | env: { 72 | ENTRY_FILE, 73 | }, 74 | }); 75 | }; 76 | 77 | export const buildHandler = async (args: CliBuildOptions) => { 78 | const config = await getConfig(args.config); 79 | const logger = new Logger(config.debug); 80 | const buildProject = args.platform === 'ios' ? buildIOS : buildAndroid; 81 | 82 | logger.print(`[OWL - CLI] Building the app on ${args.platform} platform.`); 83 | logger.info(`[OWL - CLI] Using the config file ${args.config}.`); 84 | 85 | await buildProject(config, logger); 86 | 87 | logger.info( 88 | `[OWL - CLI] Successfully built for the ${args.platform} platform.` 89 | ); 90 | }; 91 | -------------------------------------------------------------------------------- /lib/screenshot.ts: -------------------------------------------------------------------------------- 1 | import execa from 'execa'; 2 | import { promises as fs } from 'fs'; 3 | import path from 'path'; 4 | 5 | import { fileExists } from './utils/file-exists'; 6 | import { Logger } from './logger'; 7 | import { Platform } from './types'; 8 | 9 | export const cleanupScreenshots = async () => { 10 | const latestDirPath = path.join(process.cwd(), '.owl', 'latest'); 11 | await fs.rm(latestDirPath, { recursive: true, force: true }); 12 | 13 | const diffDirPath = path.join(process.cwd(), '.owl', 'diff'); 14 | await fs.rm(diffDirPath, { recursive: true, force: true }); 15 | }; 16 | 17 | /** 18 | * Takes a screenshot from the simulator. 19 | * @param filename - Required. The filename(excluding the extension) that will be used to save the screenshot. ie. 'homepage' 20 | * @returns the path to the screenshot. 21 | */ 22 | export const takeScreenshot = async (filename: string): Promise => { 23 | const platform = process.env.OWL_PLATFORM as Platform; 24 | const iosDevice = process.env.OWL_IOS_SIMULATOR as string | undefined; 25 | const iosSimulator = iosDevice?.replace(/([ /])/g, '\\$1'); 26 | const debug = process.env.OWL_DEBUG === 'true'; 27 | const updateBaseline = process.env.OWL_UPDATE_BASELINE === 'true'; 28 | const screenshotFilename = `${filename}.png`; 29 | 30 | const stdio = debug ? 'inherit' : 'ignore'; 31 | const logger = new Logger(!!debug); 32 | 33 | const screenshotsDirPath = path.join(process.cwd(), '.owl'); 34 | await fs.mkdir(screenshotsDirPath, { recursive: true }); 35 | 36 | const gitignoreExist = await fileExists( 37 | path.join(screenshotsDirPath, '.gitignore') 38 | ); 39 | 40 | if (!gitignoreExist) { 41 | await fs.writeFile( 42 | path.join(process.cwd(), '.owl', '.gitignore'), 43 | '# generated by react-native-owl\ndiff/\nlatest/\nreport/\n' 44 | ); 45 | } 46 | 47 | const baselineExist = await fileExists( 48 | path.join(screenshotsDirPath, 'baseline', platform, screenshotFilename) 49 | ); 50 | 51 | const DIR_NAME = updateBaseline || !baselineExist ? 'baseline' : 'latest'; 52 | const cwd = path.join(screenshotsDirPath, DIR_NAME, platform); 53 | await fs.mkdir(cwd, { recursive: true }); 54 | 55 | const screenshotCommand = 56 | platform === 'ios' 57 | ? `xcrun simctl io ${iosSimulator} screenshot ${screenshotFilename}` 58 | : `adb exec-out screencap -p > ${screenshotFilename}`; 59 | 60 | logger.info( 61 | `[OWL - CLI] Will run the screenshot command: ${screenshotCommand}.` 62 | ); 63 | await execa.command(screenshotCommand, { 64 | stdio, 65 | cwd, 66 | shell: platform === 'android', 67 | }); 68 | 69 | if (!baselineExist) { 70 | logger.print( 71 | `[OWL - CLI] ${screenshotFilename} baseline screenshot created.` 72 | ); 73 | } 74 | 75 | const screenshotPath = `${cwd}/${screenshotFilename}`; 76 | logger.info(`[OWL - CLI] Screenshot saved to ${screenshotPath}.`); 77 | return screenshotPath; 78 | }; 79 | -------------------------------------------------------------------------------- /lib/types.ts: -------------------------------------------------------------------------------- 1 | import { Arguments } from 'yargs'; 2 | 3 | export type Platform = 'ios' | 'android'; 4 | 5 | export interface CliBuildOptions extends Arguments { 6 | platform: Platform; 7 | config: string; 8 | } 9 | 10 | export interface CliRunOptions extends Arguments { 11 | platform: Platform; 12 | config: string; 13 | update: boolean; 14 | testNamePattern: string; 15 | testPathPattern: string; 16 | } 17 | 18 | export type ConfigEnv = { 19 | ENTRY_FILE?: string; 20 | }; 21 | 22 | export type ConfigIOS = { 23 | /** The workspace to build. */ 24 | workspace?: string; 25 | /** The scheme to build. */ 26 | scheme?: string; 27 | /** The build configuration that should be used for this target. Usually Debug or Release. */ 28 | configuration?: string; 29 | /** Overrides the `xcodebuild` command making the workspace & scheme options obselete. */ 30 | buildCommand?: string; 31 | /** Path to the .app that will get generated by a custom build command. Ignored when not using a custom build command. */ 32 | binaryPath?: string; 33 | /** Passes the quiet flag to `xcodebuild`. Does not print any output except for warnings and errors. */ 34 | quiet?: boolean; 35 | /** The name of the simulator you would like to run tests on. Can be either the name or the id of the device. */ 36 | device: string; 37 | }; 38 | 39 | export type ConfigAndroid = { 40 | /** The package name of your Android app. See Manifest.xml. */ 41 | packageName: string; 42 | /** Overrides the `assembleDebug` gradle command. Should build the apk. */ 43 | buildCommand?: string; 44 | /** Used to decided which build command it should call. */ 45 | buildType?: 'Debug' | 'Release'; 46 | /** Path to the .apk that will get generated by a custom build command. Ignored when not using a custom build command. */ 47 | binaryPath?: string; 48 | /** Passes the quiet flag to `gradlew`. */ 49 | quiet?: boolean; 50 | }; 51 | 52 | export type Config = { 53 | ios?: ConfigIOS; 54 | android?: ConfigAndroid; 55 | /** Generate an HTML report, displaying the baseline, latest & diff images. */ 56 | report?: boolean; 57 | /** Prevents the CLI/library from printing any logs/output. */ 58 | debug?: boolean; 59 | }; 60 | 61 | export type JestReport = { 62 | numFailedTestSuites: number; 63 | numFailedTests: number; 64 | numPassedTestSuites: number; 65 | numPassedTests: number; 66 | numPendingTestSuites: number; 67 | numPendingTests: number; 68 | numRuntimeErrorTestSuites: number; 69 | numTodoTests: number; 70 | numTotalTestSuites: number; 71 | numTotalTests: number; 72 | openHandles: any[]; 73 | startTime: number; 74 | success: boolean; 75 | testResults: any[]; 76 | wasInterrupted: boolean; 77 | }; 78 | 79 | export type ReportStats = { 80 | totalTestSuites: number; 81 | totalTests: number; 82 | failedTestSuites: number; 83 | failedTests: number; 84 | passedTestSuites: number; 85 | passedTests: number; 86 | duration: string; 87 | success: boolean; 88 | }; 89 | -------------------------------------------------------------------------------- /example/android/gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%" == "" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%" == "" set DIRNAME=. 29 | set APP_BASE_NAME=%~n0 30 | set APP_HOME=%DIRNAME% 31 | 32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 34 | 35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 37 | 38 | @rem Find java.exe 39 | if defined JAVA_HOME goto findJavaFromJavaHome 40 | 41 | set JAVA_EXE=java.exe 42 | %JAVA_EXE% -version >NUL 2>&1 43 | if "%ERRORLEVEL%" == "0" goto execute 44 | 45 | echo. 46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 47 | echo. 48 | echo Please set the JAVA_HOME variable in your environment to match the 49 | echo location of your Java installation. 50 | 51 | goto fail 52 | 53 | :findJavaFromJavaHome 54 | set JAVA_HOME=%JAVA_HOME:"=% 55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 56 | 57 | if exist "%JAVA_EXE%" goto execute 58 | 59 | echo. 60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 61 | echo. 62 | echo Please set the JAVA_HOME variable in your environment to match the 63 | echo location of your Java installation. 64 | 65 | goto fail 66 | 67 | :execute 68 | @rem Setup the command line 69 | 70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 71 | 72 | 73 | @rem Execute Gradle 74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 75 | 76 | :end 77 | @rem End local scope for the variables with windows NT shell 78 | if "%ERRORLEVEL%"=="0" goto mainEnd 79 | 80 | :fail 81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 82 | rem the _cmd.exe /c_ return code! 83 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 84 | exit /b 1 85 | 86 | :mainEnd 87 | if "%OS%"=="Windows_NT" endlocal 88 | 89 | :omega 90 | -------------------------------------------------------------------------------- /lib/cli/config.ts: -------------------------------------------------------------------------------- 1 | import { promises as fs } from 'fs'; 2 | import Ajv, { ErrorObject, JSONSchemaType } from 'ajv'; 3 | 4 | import { Config } from '../types'; 5 | 6 | export const validateSchema = (config: {}): Promise => { 7 | const configSchema: JSONSchemaType = { 8 | type: 'object', 9 | properties: { 10 | ios: { 11 | type: 'object', 12 | properties: { 13 | workspace: { type: 'string', nullable: true }, 14 | configuration: { type: 'string', nullable: true, default: 'Debug' }, 15 | scheme: { type: 'string', nullable: true }, 16 | buildCommand: { type: 'string', nullable: true }, 17 | binaryPath: { type: 'string', nullable: true }, 18 | device: { type: 'string' }, 19 | quiet: { type: 'boolean', nullable: true }, 20 | }, 21 | required: ['device'], 22 | anyOf: [ 23 | { required: ['workspace', 'scheme'] }, 24 | { required: ['buildCommand', 'binaryPath'] }, 25 | ], 26 | nullable: true, 27 | additionalProperties: false, 28 | }, 29 | android: { 30 | type: 'object', 31 | properties: { 32 | packageName: { type: 'string' }, 33 | buildCommand: { type: 'string', nullable: true }, 34 | buildType: { type: 'string', nullable: true, default: 'Release' }, 35 | binaryPath: { type: 'string', nullable: true }, 36 | quiet: { type: 'boolean', nullable: true }, 37 | }, 38 | required: ['packageName'], 39 | anyOf: [{ required: [] }, { required: ['buildCommand', 'binaryPath'] }], 40 | nullable: true, 41 | additionalProperties: false, 42 | }, 43 | debug: { type: 'boolean', nullable: true, default: false }, 44 | report: { type: 'boolean', nullable: true, default: true }, 45 | }, 46 | required: [], 47 | anyOf: [{ required: ['ios'] }, { required: ['android'] }], 48 | additionalProperties: false, 49 | }; 50 | 51 | const ajv = new Ajv({ useDefaults: true }); 52 | const validate = ajv.compile(configSchema); 53 | 54 | return new Promise((resolve, reject) => { 55 | if (validate(config)) { 56 | resolve(config); 57 | } else { 58 | const errorMessage = validate 59 | .errors!.map((err: ErrorObject) => `${err.schemaPath}: ${err.message}`) 60 | .join(' '); 61 | reject(errorMessage); 62 | } 63 | }); 64 | }; 65 | 66 | export const readConfigFile = async (configPath: string) => { 67 | try { 68 | const configData = await fs.readFile(configPath, 'binary'); 69 | const configString = Buffer.from(configData).toString(); 70 | const parsedConfig = JSON.parse(configString); 71 | return parsedConfig; 72 | } catch (err) { 73 | throw new Error( 74 | `Could not load the config at ${configPath}. For an example see https://formidable.com/open-source/react-native-owl/docs/introduction/config-file/` 75 | ); 76 | } 77 | }; 78 | 79 | export const getConfig = async (configPath: string): Promise => { 80 | const config = await readConfigFile(configPath); 81 | return await validateSchema(config); 82 | }; 83 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.MD: -------------------------------------------------------------------------------- 1 | Thank you for contributing! 2 | 3 | ## Development 4 | 5 | ### Installing dependencies 6 | 7 | ```sh 8 | yarn install 9 | ``` 10 | 11 | ### Testing 12 | 13 | You will find tests for files colocated with `*.test.ts` suffixes. Whenever making any changes, ensure that all existing tests pass by running `yarn test`. 14 | 15 | If you are adding a new feature or some extra functionality, you should also make sure to accompany those changes with appropriate tests. 16 | 17 | ### Linting and Formatting 18 | 19 | Before committing any changes, be sure to do `yarn prettier:check` and `yarn prettier:apply`; this will lint all relevant files using [ESLint](http://eslint.org/) and report on any changes that you need to make. 20 | 21 | ### Before submitting a PR... 22 | 23 | Thanks for taking the time to help us make react-native-owl even better! Before you go ahead and submit a PR, make sure that you have done the following: 24 | 25 | - Run the tests using `yarn test`. 26 | - Run lint and flow using `yarn prettier:check` and `yarn prettier:apply` 27 | - Run `yarn changeset` 28 | 29 | ### Using changesets 30 | 31 | Our official release path is to use automation to perform the actual publishing of our packages. The steps are to: 32 | 33 | 1. A human developer adds a changeset. Ideally this is as a part of a PR that will have a version impact on a package. 34 | 2. On merge of a PR our automation system opens a "Version Packages" PR. 35 | 3. On merging the "Version Packages" PR, the automation system publishes the packages. 36 | 37 | Here are more details: 38 | 39 | ### Add a changeset 40 | 41 | When you would like to add a changeset (which creates a file indicating the type of change), in your branch/PR issue this command: 42 | 43 | ```sh 44 | $ yarn changeset 45 | ``` 46 | 47 | to produce an interactive menu. Navigate the packages with arrow keys and hit `` to select 1+ packages. Hit `` when done. Select semver versions for packages and add appropriate messages. From there, you'll be prompted to enter a summary of the change. Some tips for this summary: 48 | 49 | 1. Aim for a single line, 1+ sentences as appropriate. 50 | 2. Include issue links in GH format (e.g. `#123`). 51 | 3. You don't need to reference the current pull request or whatnot, as that will be added later automatically. 52 | 53 | After this, you'll see a new uncommitted file in `.changesets` like: 54 | 55 | ```sh 56 | $ git status 57 | # .... 58 | Untracked files: 59 | (use "git add ..." to include in what will be committed) 60 | .changeset/flimsy-pandas-marry.md 61 | ``` 62 | 63 | Review the file, make any necessary adjustments, and commit it to source. When we eventually do a package release, the changeset notes and version will be incorporated! 64 | 65 | ### Creating versions 66 | 67 | On a merge of a feature PR, the changesets GitHub action will open a new PR titled `"Version Packages"`. This PR is automatically kept up to date with additional PRs with changesets. So, if you're not ready to publish yet, just keep merging feature PRs and then merge the version packages PR later. 68 | 69 | ### Publishing packages 70 | 71 | On the merge of a version packages PR, the changesets GitHub action will publish the packages to npm. 72 | -------------------------------------------------------------------------------- /lib/report.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import handlebars from 'handlebars'; 3 | import { promises as fs } from 'fs'; 4 | 5 | import { Logger } from './logger'; 6 | import { JestReport, Platform, ReportStats } from './types'; 7 | import { fileExists } from './utils/file-exists'; 8 | 9 | export const cleanupReport = async () => { 10 | const cwd = process.cwd(); 11 | const reportDirPath = path.join(cwd, '.owl', 'report'); 12 | 13 | await fs.rm(reportDirPath, { recursive: true, force: true }); 14 | }; 15 | 16 | export const generateReport = async (logger: Logger, platform: Platform) => { 17 | const cwd = process.cwd(); 18 | const reportDirPath = path.join(cwd, '.owl', 'report'); 19 | 20 | const jestOutputFilepath = path.join(reportDirPath, 'jest-report.json'); 21 | const jestOutputText = await fs.readFile(jestOutputFilepath, 'utf8'); 22 | const jestOutput = JSON.parse(jestOutputText) as JestReport; 23 | 24 | const diffScreenshotsDirPath = path.join(cwd, '.owl', 'diff', platform); 25 | const baselineScreenshotsDirPath = path.join( 26 | cwd, 27 | '.owl', 28 | 'baseline', 29 | platform 30 | ); 31 | 32 | const baselineScreenshotsDirExists = await fileExists( 33 | baselineScreenshotsDirPath 34 | ); 35 | if (!baselineScreenshotsDirExists) { 36 | logger.print( 37 | `[OWL - CLI] Generating report skipped as is no baseline screenshots directory` 38 | ); 39 | 40 | return; 41 | } 42 | 43 | const baselineScreenshots = await fs.readdir(baselineScreenshotsDirPath); 44 | const failingScreenshots = (await fileExists(diffScreenshotsDirPath)) 45 | ? await fs.readdir(diffScreenshotsDirPath) 46 | : []; 47 | 48 | const passingScreenshots = baselineScreenshots.filter( 49 | (screenshot) => !failingScreenshots.includes(screenshot) 50 | ); 51 | 52 | const duration = (Date.now() - jestOutput.startTime) / 1000; 53 | const durationFormatted = parseFloat(`${duration}`).toFixed(2); 54 | 55 | const stats: ReportStats = { 56 | totalTestSuites: jestOutput.numTotalTestSuites, 57 | totalTests: jestOutput.numTotalTests, 58 | failedTestSuites: jestOutput.numFailedTestSuites, 59 | failedTests: jestOutput.numFailedTests, 60 | passedTestSuites: jestOutput.numPassedTestSuites, 61 | passedTests: jestOutput.numPassedTests, 62 | duration: durationFormatted, 63 | success: jestOutput.success, 64 | }; 65 | 66 | logger.info(`[OWL - CLI] Generating Report`); 67 | 68 | const reportFilename = 'index.html'; 69 | const entryFile = path.join(__dirname, 'report', reportFilename); 70 | const htmlTemplate = await fs.readFile(entryFile, 'utf-8'); 71 | const templateScript = handlebars.compile(htmlTemplate); 72 | const htmlContent = templateScript({ 73 | currentYear: new Date().getFullYear(), 74 | currentDateTime: new Date().toUTCString(), 75 | platform, 76 | failingScreenshots, 77 | passingScreenshots, 78 | stats, 79 | }); 80 | 81 | await fs.mkdir(reportDirPath, { recursive: true }); 82 | const reportFilePath = path.join(reportDirPath, 'index.html'); 83 | await fs.writeFile(reportFilePath, htmlContent); 84 | 85 | logger.print( 86 | `[OWL - CLI] Report was built at ${reportDirPath}/${reportFilename}` 87 | ); 88 | }; 89 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![React Native Owl](https://oss.nearform.com/api/banner?badge=owl&bg=95dd77&text=react+native+owl)](https://commerce.nearform.com/open-source/react-native-owl/) 2 | 3 | `react-native-owl` — Visual regression testing for React Native 4 | 5 | ![Sample of using React Native Owl to generate a visual regression diff](https://raw.githubusercontent.com/FormidableLabs/react-native-owl/main/website/static/images/homepage/mockup-bg.png) 6 | 7 | --- 8 | 9 | [![github][github-image]][github-url] [![npm][npm-image]][npm-url] [![docs][docs-image]][docs-url] [![Maintenance Status][maintenance-image]](#maintenance-status) 10 | 11 | ## What is React Native Owl? 12 | 13 | React Native Owl is a visual regression testing library for React Native that enables developers to introduce visual regression tests to their apps for iOS and Android. Being heavily inspired by [Detox](https://github.com/wix/Detox), an end-to-end testing and automation framework, this library uses a similar API that makes setting up `react-native-owl` and running the tests locally and on your preferred CI service seamless. 14 | 15 | While Detox focuses on end-to-end testing in general, React Native Owl focuses on visual regression testing and helping you catch unexpected changes to the visual appearance of your app over time. 16 | 17 | ### :rocket: [Check out the official documentation for more details!](https://formidable.com/open-source/react-native-owl/) 18 | 19 | ## Installation 20 | 21 | ```sh 22 | yarn add -D react-native-owl 23 | # or 24 | npm install -D react-native-owl 25 | ``` 26 | 27 | ## 📃 [Documentation](https://formidable.com/open-source/react-native-owl/) 28 | 29 | The documentation contains everything you need to know about `react-native-owl`, and contains several sections in order of importance 30 | when you first get started: 31 | 32 | - **[Getting started](https://formidable.com/open-source/react-native-owl/docs/introduction/getting-started)** — contains the "Getting Started" guide. 33 | - **[Configuration](https://formidable.com/open-source/react-native-owl/docs/introduction/config-file)** — explains all the configuration options for `react-native-owl`. 34 | - **[Running on CI](https://formidable.com/open-source/react-native-owl/docs/ci/github-actions)** — example GitHub Action to run react-native-owl on CI. 35 | 36 | _You can find the raw markdown files inside this repository's `docs` folder._ 37 | 38 | ## Contributing 39 | 40 | Please see our [contributing guide](./.github/CONTRIBUTING.MD). 41 | 42 | ## Maintenance Status 43 | 44 | **Active:** Formidable is actively working on this project, and we expect to continue work on this project for the foreseeable future. Bug reports, feature requests and pull requests are welcome. 45 | 46 | [github-image]: https://github.com/FormidableLabs/react-native-owl/workflows/Run%20Tests/badge.svg 47 | [github-url]: https://github.com/FormidableLabs/react-native-owl/actions 48 | [npm-image]: https://img.shields.io/npm/v/react-native-owl 49 | [npm-url]: https://www.npmjs.com/package/react-native-owl 50 | [docs-image]: https://img.shields.io/badge/docs-visit%20site-blue 51 | [docs-url]: https://formidable.com/open-source/react-native-owl/ 52 | [maintenance-image]: https://img.shields.io/badge/maintenance-active-green.svg?color=brightgreen&style=flat 53 | -------------------------------------------------------------------------------- /docs/introduction/config-file.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 2 3 | --- 4 | 5 | # Config File 6 | 7 | The config file - which unless specified in the cli should live in `./owl.config.json` - is used to describe how Owl should run your app and your tests. Below you can find all the options that can be specified. 8 | 9 | ### Options 10 | 11 | | Name | Required | Default | Description | 12 | | ---------------------- | -------- | --------- | -------------------------------------------------------------------------------------------- | 13 | | **general** | | | | 14 | | `debug` | false | `false` | Prevents the CLI/library from printing any logs/output. | 15 | | `report` | false | `true` | Generate an HTML report, displaying the baseline, latest & diff images. | 16 | | **ios config** | | | | 17 | | `ios.workspace` | true | | Path to the `.xcworkspace` file of your react-native project | 18 | | `ios.scheme` | true | | The name of the scheme you would like to use for building the app | 19 | | `ios.configuration` | true | `Debug` | The build configuration that should be used. | 20 | | `ios.buildCommand` | false | | Overrides the `xcodebuild` command making the above options obselete | 21 | | `ios.binaryPath` | false | | The path to the binary, if you are using a custom build command | 22 | | `ios.quiet` | false | | Passes the quiet flag to `xcode builds` | 23 | | **android config** | | | | 24 | | `android.buildCommand` | false | | Overrides the `assembleDebug` gradle command. Should build the apk | 25 | | `android.buildType` | false | `Release` | Can be one of `debug` or `release`. Used to call either `assembleDebug` or `assembleRelease` | 26 | | `android.binaryPath` | false | | The path to the binary, if you are using a custom build command | 27 | | `android.packageName` | true | | The package name/unique identifier of the app | 28 | | `android.quiet` | false | | Passes the quiet flag to `gradlew` | 29 | 30 | ### Example 31 | 32 | ```json title="owl.config.json" 33 | { 34 | "ios": { 35 | "workspace": "ios/OwlDemoApp.xcworkspace", 36 | "scheme": "OwlDemoApp", 37 | "device": "iPhone 13 Pro" 38 | }, 39 | "android": { 40 | "packageName": "com.owldemoapp" 41 | } 42 | } 43 | ``` 44 | -------------------------------------------------------------------------------- /example/ios/OwlDemo.xcodeproj/xcshareddata/xcschemes/OwlDemo.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 33 | 39 | 40 | 41 | 42 | 43 | 53 | 55 | 61 | 62 | 63 | 64 | 70 | 72 | 78 | 79 | 80 | 81 | 83 | 84 | 87 | 88 | 89 | -------------------------------------------------------------------------------- /example/android/app/src/debug/java/com/owldemo/ReactNativeFlipper.java: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Meta Platforms, Inc. and affiliates. 3 | * 4 | *

This source code is licensed under the MIT license found in the LICENSE file in the root 5 | * directory of this source tree. 6 | */ 7 | package com.owldemo; 8 | 9 | import android.content.Context; 10 | import com.facebook.flipper.android.AndroidFlipperClient; 11 | import com.facebook.flipper.android.utils.FlipperUtils; 12 | import com.facebook.flipper.core.FlipperClient; 13 | import com.facebook.flipper.plugins.crashreporter.CrashReporterPlugin; 14 | import com.facebook.flipper.plugins.databases.DatabasesFlipperPlugin; 15 | import com.facebook.flipper.plugins.fresco.FrescoFlipperPlugin; 16 | import com.facebook.flipper.plugins.inspector.DescriptorMapping; 17 | import com.facebook.flipper.plugins.inspector.InspectorFlipperPlugin; 18 | import com.facebook.flipper.plugins.network.FlipperOkhttpInterceptor; 19 | import com.facebook.flipper.plugins.network.NetworkFlipperPlugin; 20 | import com.facebook.flipper.plugins.sharedpreferences.SharedPreferencesFlipperPlugin; 21 | import com.facebook.react.ReactInstanceEventListener; 22 | import com.facebook.react.ReactInstanceManager; 23 | import com.facebook.react.bridge.ReactContext; 24 | import com.facebook.react.modules.network.NetworkingModule; 25 | import okhttp3.OkHttpClient; 26 | 27 | /** 28 | * Class responsible of loading Flipper inside your React Native application. This is the debug 29 | * flavor of it. Here you can add your own plugins and customize the Flipper setup. 30 | */ 31 | public class ReactNativeFlipper { 32 | public static void initializeFlipper(Context context, ReactInstanceManager reactInstanceManager) { 33 | if (FlipperUtils.shouldEnableFlipper(context)) { 34 | final FlipperClient client = AndroidFlipperClient.getInstance(context); 35 | 36 | client.addPlugin(new InspectorFlipperPlugin(context, DescriptorMapping.withDefaults())); 37 | client.addPlugin(new DatabasesFlipperPlugin(context)); 38 | client.addPlugin(new SharedPreferencesFlipperPlugin(context)); 39 | client.addPlugin(CrashReporterPlugin.getInstance()); 40 | 41 | NetworkFlipperPlugin networkFlipperPlugin = new NetworkFlipperPlugin(); 42 | NetworkingModule.setCustomClientBuilder( 43 | new NetworkingModule.CustomClientBuilder() { 44 | @Override 45 | public void apply(OkHttpClient.Builder builder) { 46 | builder.addNetworkInterceptor(new FlipperOkhttpInterceptor(networkFlipperPlugin)); 47 | } 48 | }); 49 | client.addPlugin(networkFlipperPlugin); 50 | client.start(); 51 | 52 | // Fresco Plugin needs to ensure that ImagePipelineFactory is initialized 53 | // Hence we run if after all native modules have been initialized 54 | ReactContext reactContext = reactInstanceManager.getCurrentReactContext(); 55 | if (reactContext == null) { 56 | reactInstanceManager.addReactInstanceEventListener( 57 | new ReactInstanceEventListener() { 58 | @Override 59 | public void onReactContextInitialized(ReactContext reactContext) { 60 | reactInstanceManager.removeReactInstanceEventListener(this); 61 | reactContext.runOnNativeModulesQueueThread( 62 | new Runnable() { 63 | @Override 64 | public void run() { 65 | client.addPlugin(new FrescoFlipperPlugin()); 66 | } 67 | }); 68 | } 69 | }); 70 | } else { 71 | client.addPlugin(new FrescoFlipperPlugin()); 72 | } 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /.github/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | - Using welcoming and inclusive language 18 | - Being respectful of differing viewpoints and experiences 19 | - Gracefully accepting constructive criticism 20 | - Focusing on what is best for the community 21 | - Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | - The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | - Trolling, insulting/derogatory comments, and personal or political attacks 28 | - Public or private harassment 29 | - Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | - Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team: 59 | 60 | - emmanouil.konstantinidis@formidable.com 61 | - robert.walker@formidable.com 62 | 63 | All complaints will be reviewed and investigated and will result in a response that 64 | is deemed necessary and appropriate to the circumstances. The project team is 65 | obligated to maintain confidentiality with regard to the reporter of an incident. 66 | Further details of specific enforcement policies may be posted separately. 67 | 68 | Project maintainers who do not follow or enforce the Code of Conduct in good 69 | faith may face temporary or permanent repercussions as determined by other 70 | members of the project's leadership. 71 | 72 | ## Attribution 73 | 74 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 75 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 76 | 77 | [homepage]: https://www.contributor-covenant.org 78 | 79 | For answers to common questions about this code of conduct, see 80 | https://www.contributor-covenant.org/faq -------------------------------------------------------------------------------- /lib/client/handleAction.ts: -------------------------------------------------------------------------------- 1 | import { GestureResponderEvent } from 'react-native'; 2 | import { 3 | SOCKET_TEST_ACTION, 4 | SOCKET_TEST_REQUEST_VALUE, 5 | } from '../websocketTypes'; 6 | import { Logger } from '../logger'; 7 | import { TrackedElementData } from './trackedElements'; 8 | 9 | /** 10 | * When we call onPress/onLongPress, the function expects an `event` arg of type `GestureResponderEvent`. 11 | * To try to prevent errors in where the onPress/onLongPress function uses the event data, we create some mock event data. 12 | */ 13 | const getGestureResponderEvent = (): GestureResponderEvent => ({ 14 | nativeEvent: { 15 | changedTouches: [], 16 | identifier: 'OWL-identifier', 17 | locationX: 0, 18 | locationY: 0, 19 | pageX: 0, 20 | pageY: 0, 21 | target: 'OWL-target', 22 | timestamp: Date.now(), 23 | touches: [], 24 | }, 25 | currentTarget: 0, 26 | target: 0, 27 | bubbles: false, 28 | cancelable: false, 29 | defaultPrevented: false, 30 | eventPhase: 0, 31 | isTrusted: true, 32 | preventDefault: () => {}, 33 | isDefaultPrevented: () => false, 34 | stopPropagation: () => {}, 35 | isPropagationStopped: () => false, 36 | persist: () => {}, 37 | timeStamp: Date.now(), 38 | type: 'RCTView', 39 | }); 40 | 41 | /** 42 | * This function handles the individual actions that are requested in the jest tests. 43 | * For each action, we first check that we have the method and value required to perform the action. 44 | * Then we perform it, normally by calling the callback being used for a specific element prop, 45 | * or by calling a method on the element's ref. 46 | * The thrown error message will be displayed in the jest test results. 47 | */ 48 | export const handleAction = ( 49 | logger: Logger, 50 | testID: string, 51 | element: TrackedElementData, 52 | action: SOCKET_TEST_ACTION, 53 | value?: SOCKET_TEST_REQUEST_VALUE 54 | ) => { 55 | logger.info( 56 | `[OWL - Client] Executing ${action} on element with testID ${testID}` 57 | ); 58 | 59 | switch (action) { 60 | case 'PRESS': 61 | if (!element.onPress) { 62 | throw new Error(`This element has no onPress prop`); 63 | } 64 | 65 | element.onPress(getGestureResponderEvent()); 66 | break; 67 | 68 | case 'LONG_PRESS': 69 | if (!element.onLongPress) { 70 | throw new Error(`This element has no onLongPress prop`); 71 | } 72 | 73 | element.onLongPress(getGestureResponderEvent()); 74 | break; 75 | 76 | case 'CHANGE_TEXT': 77 | if (!element.onChangeText) { 78 | throw new Error(`This element has no onChangeText prop`); 79 | } 80 | 81 | element.onChangeText( 82 | typeof value === 'undefined' ? '' : value.toString() 83 | ); 84 | break; 85 | 86 | case 'SCROLL_TO': 87 | if (!element.ref.current?.scrollTo) { 88 | throw new Error(`This element has no scrollTo method`); 89 | } 90 | 91 | if ( 92 | typeof value !== 'object' || 93 | (value.x === undefined && value.y === undefined) 94 | ) { 95 | throw new Error(`Value must include x and/or y properties`); 96 | } 97 | 98 | element.ref.current.scrollTo({ ...value, animated: false }); 99 | break; 100 | 101 | case 'SCROLL_TO_END': 102 | if (!element.ref.current?.scrollToEnd) { 103 | throw new Error(`This element has no scrollToEnd method`); 104 | } 105 | 106 | element.ref.current.scrollToEnd({ animated: false }); 107 | break; 108 | 109 | default: 110 | throw new Error(`Action '${action}' not supported `); 111 | } 112 | }; 113 | -------------------------------------------------------------------------------- /docs/cli/testing-the-app.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 2 3 | --- 4 | 5 | import Tabs from '@theme/Tabs'; 6 | import TabItem from '@theme/TabItem'; 7 | 8 | # Testing the app 9 | 10 | Use the `test` command to run the app on the simulator, either comparing screenshots with the baseline images, or updating the baseline images. 11 | 12 | #### Options 13 | 14 | | Name | Required | Default | Options/Types | Description | 15 | | -------------------------- | -------- | ----------------- | --------------- | ------------------------------------------------- | 16 | | `--config`, `-c` | false | ./owl.config.json | String | Path to the configuration file | 17 | | `--platform`, `-p` | true | - | `ios`,`android` | The platform the app should be built on | 18 | | `--update`, `-u` | true | false | Boolean | A flag about rewriting existing baseline images | 19 | | `--testNamePattern`, `-t` | false | false | String | Run only tests with a name that matches the regex | 20 | | `--testPathPattern`, `-tp` | false | false | String | A regexp string matched against all tests path | 21 | 22 | When comparing images, any difference in the current vs baseline will fail the test. 23 | 24 | :::info 25 | 26 | The **first** time you will run the test command, react-native-owl will generate all your baseline images. It is _very_ important to make sure these are correct before proceeding. 27 | 28 | ::: 29 | 30 | :::info 31 | 32 | You will need to manually start the correct simulator before the tests are run. 33 | 34 | ::: 35 | 36 | ### First run 37 | 38 | The baseline images will be automatically generated. To regenerate the baseline images, use the `--update` option. 39 | 40 | ### Running tests 41 | 42 | 43 | 44 | 45 | ```bash 46 | npx owl test --platform ios 47 | ``` 48 | 49 | 50 | 51 | 52 | ```bash 53 | yarn owl test --platform ios 54 | ``` 55 | 56 | 57 | 58 | 59 | ### Updating the baseline 60 | 61 | Update the baseline images 62 | 63 | 64 | 65 | 66 | ```bash 67 | npx owl test --platform ios --update 68 | ``` 69 | 70 | 71 | 72 | 73 | ```bash 74 | yarn owl test --platform ios --update 75 | ``` 76 | 77 | 78 | 79 | 80 | ### Using a custom config file 81 | 82 | Update the baseline images 83 | 84 | 85 | 86 | 87 | ```bash 88 | npx owl test --platform ios --config ./owl.config.json 89 | ``` 90 | 91 | 92 | 93 | 94 | ```bash 95 | yarn owl test --platform ios --config ./owl.config.json 96 | ``` 97 | 98 | 99 | 100 | 101 | ### Viewing the report 102 | 103 | When the tests have failed any [`.toMatchBaseline()`](/docs/api/matchers) expectations, a report is generated, where you can view all the screenshots, where the differences in the current vs baseline screenshots will be highlighted. 104 | 105 | The report uri is included in the test output. 106 | 107 | #### Example: 108 | 109 | The following will be included in the output of failed tests: 110 | 111 | ``` 112 | ... 113 | [OWL - CLI] Generating Report 114 | [OWL - CLI] Report was built at /Users/username/Code/FormidableLabs/react-native-owl/example/.owl/report/index.html 115 | ... 116 | ``` 117 | -------------------------------------------------------------------------------- /lib/utils/adb.test.ts: -------------------------------------------------------------------------------- 1 | import execa from 'execa'; 2 | 3 | import * as adb from './adb'; 4 | 5 | describe('adb.ts', () => { 6 | jest 7 | .spyOn(process, 'cwd') 8 | .mockReturnValue('/Users/johndoe/Projects/my-project'); 9 | 10 | const execKillMock = { 11 | kill: jest.fn(), 12 | } as unknown as execa.ExecaChildProcess; 13 | const execMock = jest.spyOn(execa, 'command').mockReturnValue(execKillMock); 14 | 15 | beforeEach(() => { 16 | execMock.mockReset(); 17 | }); 18 | 19 | describe('adbInstall', () => { 20 | it('installs an app with default config', async () => { 21 | await adb.adbInstall({}); 22 | 23 | expect(execMock).toHaveBeenCalledTimes(1); 24 | expect(execMock).toHaveBeenCalledWith( 25 | 'adb install -r /Users/johndoe/Projects/my-project/android/app/build/outputs/apk/release/app-release.apk', 26 | { stdio: 'ignore' } 27 | ); 28 | }); 29 | 30 | it('installs an app with debugging', async () => { 31 | await adb.adbInstall({ debug: true }); 32 | 33 | expect(execMock).toHaveBeenCalledTimes(1); 34 | expect(execMock).toHaveBeenCalledWith( 35 | 'adb install -r /Users/johndoe/Projects/my-project/android/app/build/outputs/apk/release/app-release.apk', 36 | { stdio: 'inherit' } 37 | ); 38 | }); 39 | 40 | it('installs an app with custom buildType', async () => { 41 | await adb.adbInstall({ 42 | buildType: 'Debug', 43 | }); 44 | 45 | expect(execMock).toHaveBeenCalledTimes(1); 46 | expect(execMock).toHaveBeenCalledWith( 47 | 'adb install -r /Users/johndoe/Projects/my-project/android/app/build/outputs/apk/debug/app-debug.apk', 48 | { stdio: 'ignore' } 49 | ); 50 | }); 51 | 52 | it('installs an app with custom binaryPath', async () => { 53 | await adb.adbInstall({ 54 | binaryPath: '/custom/path/app.apk', 55 | }); 56 | 57 | expect(execMock).toHaveBeenCalledTimes(1); 58 | expect(execMock).toHaveBeenCalledWith( 59 | 'adb install -r /custom/path/app.apk', 60 | { stdio: 'ignore' } 61 | ); 62 | }); 63 | }); 64 | 65 | describe('adbTerminate', () => { 66 | it('terminates an app', async () => { 67 | await adb.adbTerminate({ packageName: 'com.name.app' }); 68 | 69 | expect(execMock).toHaveBeenCalledTimes(1); 70 | expect(execMock).toHaveBeenCalledWith( 71 | 'adb shell am force-stop com.name.app', 72 | { stdio: 'ignore' } 73 | ); 74 | }); 75 | 76 | it('terminates an app with debugging', async () => { 77 | await adb.adbTerminate({ debug: true, packageName: 'com.name.app' }); 78 | 79 | expect(execMock).toHaveBeenCalledTimes(1); 80 | expect(execMock).toHaveBeenCalledWith( 81 | 'adb shell am force-stop com.name.app', 82 | { stdio: 'inherit' } 83 | ); 84 | }); 85 | }); 86 | 87 | describe('adbLaunch', () => { 88 | it('launches an app', async () => { 89 | await adb.adbLaunch({ packageName: 'com.name.app' }); 90 | 91 | expect(execMock).toHaveBeenCalledTimes(1); 92 | expect(execMock).toHaveBeenCalledWith( 93 | 'adb shell monkey -p "com.name.app" -c android.intent.category.LAUNCHER 1', 94 | { stdio: 'ignore' } 95 | ); 96 | }); 97 | 98 | it('launches an app with debugging', async () => { 99 | await adb.adbLaunch({ debug: true, packageName: 'com.name.app' }); 100 | 101 | expect(execMock).toHaveBeenCalledTimes(1); 102 | expect(execMock).toHaveBeenCalledWith( 103 | 'adb shell monkey -p "com.name.app" -c android.intent.category.LAUNCHER 1', 104 | { stdio: 'inherit' } 105 | ); 106 | }); 107 | }); 108 | }); 109 | -------------------------------------------------------------------------------- /website/docusaurus.config.ts: -------------------------------------------------------------------------------- 1 | import { themes as prismThemes } from "prism-react-renderer" ; 2 | import type { Config } from "@docusaurus/types"; 3 | import type * as Preset from "@docusaurus/preset-classic"; 4 | 5 | const config: Config = { 6 | title: 'React Native Owl', 7 | tagline: 'Visual Regression Testing for React Native', 8 | url: 'https://commerce.nearform.com/', 9 | baseUrl: '/open-source/react-native-owl/', 10 | onBrokenLinks: 'throw', 11 | onBrokenMarkdownLinks: 'warn', 12 | favicon: 'images/favicon.ico', 13 | organizationName: 'Nearform Commerce', 14 | projectName: 'react-native-owl', 15 | i18n: { 16 | defaultLocale: "en", 17 | locales: ["en"], 18 | }, 19 | presets: [ 20 | [ 21 | "classic", 22 | { 23 | docs: { 24 | path: '../docs', 25 | sidebarPath: './sidebars.js', 26 | editUrl: 27 | 'https://github.com/FormidableLabs/react-native-owl/edit/main/website/', 28 | }, 29 | theme: { 30 | customCss: require.resolve('./src/css/custom.css'), 31 | }, 32 | ...(process.env.VERCEL_ENV === 'production' && { 33 | gtag: { 34 | trackingID: process.env.GTAG_TRACKING_ID, 35 | anonymizeIP: true, 36 | }, 37 | googleTagManager: { 38 | containerId: process.env.GTM_CONTAINER_ID, 39 | }, 40 | }), 41 | }, 42 | ], 43 | ], 44 | 45 | themeConfig: { 46 | metadata: [ 47 | { 48 | name: "viewport", 49 | content: "width=device-width, initial-scale=1, maximum-scale=1", 50 | image: '/images/social.png', 51 | }, 52 | ], 53 | navbar: { 54 | style: 'dark', 55 | title: 'React Native Owl', 56 | logo: { 57 | alt: 'React Native Owl Logo', 58 | src: 'images/logo-eyes.svg', 59 | }, 60 | items: [ 61 | { 62 | label: 'Documentation', 63 | position: 'left', 64 | items: [ 65 | { 66 | label: 'Getting Started', 67 | to: '/docs/introduction/getting-started/', 68 | }, 69 | { 70 | label: 'Config File', 71 | to: '/docs/introduction/config-file/', 72 | }, 73 | { 74 | label: 'CLI', 75 | to: '/docs/cli/building-the-app/', 76 | }, 77 | { 78 | label: 'Methods', 79 | to: '/docs/api/methods/', 80 | }, 81 | { 82 | label: 'Matchers', 83 | to: '/docs/api/matchers/', 84 | }, 85 | ], 86 | }, 87 | { 88 | href: 'https://github.com/FormidableLabs/react-native-owl', 89 | className: 'header-github-link', 90 | 'aria-label': 'GitHub Repository', 91 | position: 'right', 92 | }, 93 | { 94 | href: 'https://commerce.nearform.com/', 95 | className: 'header-nearform-link', 96 | 'aria-label': 'Nearform Commerce Website', 97 | position: 'right', 98 | }, 99 | ], 100 | }, 101 | footer: { 102 | style: 'dark', 103 | logo: { 104 | alt: "Nearform logo", 105 | src: "images/nearform-logo-white.svg", 106 | href: "https://commerce.nearform.com", 107 | width: 100, 108 | height: 100, 109 | }, 110 | copyright: `Copyright © ${new Date().getFullYear()} Nearform`, 111 | }, 112 | prism: { 113 | defaultLanguage: 'javascript', 114 | theme: prismThemes.github, 115 | darkTheme: prismThemes.dracula, 116 | }, 117 | } satisfies Preset.ThemeConfig, 118 | }; 119 | 120 | export default config; 121 | -------------------------------------------------------------------------------- /website/src/css/custom.css: -------------------------------------------------------------------------------- 1 | /** 2 | * Any CSS included here will be global. The classic template 3 | * bundles Infima by default. Infima is a CSS framework designed to 4 | * work well for content-centric websites. 5 | */ 6 | 7 | /* You can override the default Infima variables here. */ 8 | :root { 9 | --brand-blue: #000e38; 10 | --brand-primary: #95dd77; 11 | --brand-neutral: #f6f8fa; 12 | 13 | --ifm-navbar-height: 4.25rem; 14 | 15 | --ifm-h3-font-size: 2.2rem; 16 | --ifm-code-font-size: 95%; 17 | 18 | --ifm-footer-padding-vertical: 1rem; 19 | } 20 | 21 | html[data-theme='dark'] { 22 | /* Overrides */ 23 | } 24 | 25 | .navbar--dark, 26 | .hero--dark, 27 | .footer--dark { 28 | --ifm-navbar-background-color: var(--brand-blue); 29 | --ifm-footer-background-color: var(--brand-blue); 30 | 31 | box-shadow: initial; 32 | } 33 | 34 | .docusaurus-highlight-code-line { 35 | background-color: rgba(0, 0, 0, 0.1); 36 | display: block; 37 | margin: 0 calc(-1 * var(--ifm-pre-padding)); 38 | padding: 0 var(--ifm-pre-padding); 39 | } 40 | 41 | html[data-theme='dark'] .docusaurus-highlight-code-line { 42 | background-color: rgba(0, 0, 0, 0.3); 43 | } 44 | 45 | .header-github-link:hover { 46 | opacity: 0.6; 47 | } 48 | 49 | .header-github-link:before { 50 | content: ''; 51 | width: 24px; 52 | height: 24px; 53 | display: flex; 54 | background: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill='white' d='M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12'/%3E%3C/svg%3E") 55 | no-repeat; 56 | } 57 | 58 | .header-nearform-link:hover { 59 | opacity: 0.6; 60 | } 61 | 62 | .header-nearform-link:before { 63 | content: ''; 64 | width: 32px; 65 | height: 24px; 66 | display: flex; 67 | background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='60 60 111.98 85.72'%3E%3Cpath class='cls-2' d='m60.18,60.18h11.3l36.71,53.4v-53.4h12.17v75.36h-11.3l-36.71-53.4v53.4h-12.17V60.18Z' stroke-width='0' fill='%23fff'/%3E%3Cpath class='cls-1' d='m126.51,135.54h45.29v11.09h-45.29v-11.09Z' fill='%2300e5a4'/%3E%3C/svg%3E") 68 | no-repeat; 69 | } 70 | 71 | /* Content */ 72 | 73 | .main-wrapper { 74 | max-width: 1400px; 75 | width: 1400px; 76 | align-self: center; 77 | } 78 | 79 | @media (max-width: 1416px) { 80 | .main-wrapper { 81 | max-width: 100%; 82 | width: 100%; 83 | } 84 | } 85 | 86 | .homepage { 87 | width: 100%; 88 | max-width: 100%; 89 | } 90 | 91 | .homepage main { 92 | padding: 0 2rem; 93 | } 94 | 95 | .homepage .intro { 96 | font-size: 1.25rem; 97 | } 98 | 99 | /* Navbar */ 100 | 101 | .navbar .navbar__inner { 102 | max-width: 1360px; 103 | margin: 0 auto; 104 | } 105 | 106 | @media (max-width: 1416px) { 107 | .main-wrapper { 108 | max-width: 100%; 109 | width: 100%; 110 | } 111 | } 112 | 113 | /* Footer */ 114 | .footer__bottom.text--center { 115 | display: flex; 116 | justify-content: space-between; 117 | align-items: center; 118 | } 119 | 120 | .footer__bottom.text--center a { 121 | opacity: 1 !important; 122 | } 123 | 124 | .footer__copyright { 125 | color: white; 126 | } 127 | -------------------------------------------------------------------------------- /website/static/images/logo-eyes.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | RN Owl logo 4 | 18 | 19 | -------------------------------------------------------------------------------- /lib/actions.ts: -------------------------------------------------------------------------------- 1 | import { getConfig } from './cli/config'; 2 | 3 | import { Logger } from './logger'; 4 | import { CliRunOptions } from './types'; 5 | import { adbLaunch, adbTerminate } from './utils/adb'; 6 | import { waitFor } from './utils/wait-for'; 7 | import { xcrunLaunch, xcrunTerminate, xcrunUi } from './utils/xcrun'; 8 | import { createWebSocketClient } from './websocket'; 9 | import { 10 | SOCKET_TEST_REQUEST, 11 | SOCKET_SCROLL_TO_VALUE, 12 | SOCKET_CLIENT_RESPONSE, 13 | } from './websocketTypes'; 14 | 15 | const logger = new Logger(process.env.OWL_DEBUG === 'true'); 16 | 17 | const sendEvent = async (event: SOCKET_TEST_REQUEST) => 18 | new Promise(async (resolve, reject) => { 19 | // Create a websocket client just for this event request/response cycle. 20 | const actionsWebSocketClient = await createWebSocketClient( 21 | logger, 22 | (message) => { 23 | // Close this connection 24 | actionsWebSocketClient.close(); 25 | 26 | // The message received here indicates the outcome of the action we sent to the app client 27 | const event = JSON.parse(message) as SOCKET_CLIENT_RESPONSE; 28 | 29 | switch (event.type) { 30 | case 'DONE': 31 | resolve(true); 32 | break; 33 | case 'NOT_FOUND': 34 | reject(`Element not found: ${event.testID}`); 35 | break; 36 | case 'ERROR': 37 | reject(`Element error: ${event.testID} - ${event.message}`); 38 | break; 39 | default: 40 | reject('Unknown onMessage event type'); 41 | break; 42 | } 43 | } 44 | ); 45 | 46 | actionsWebSocketClient.send(JSON.stringify(event)); 47 | }); 48 | 49 | export const press = (testID: string) => 50 | sendEvent({ type: 'ACTION', action: 'PRESS', testID }); 51 | 52 | export const longPress = (testID: string) => 53 | sendEvent({ type: 'ACTION', action: 'LONG_PRESS', testID }); 54 | 55 | export const changeText = (testID: string, value: string) => 56 | sendEvent({ type: 'ACTION', action: 'CHANGE_TEXT', testID, value }); 57 | 58 | export const scrollTo = (testID: string, value: SOCKET_SCROLL_TO_VALUE) => 59 | sendEvent({ type: 'ACTION', action: 'SCROLL_TO', testID, value }); 60 | 61 | export const scrollToEnd = (testID: string) => 62 | sendEvent({ type: 'ACTION', action: 'SCROLL_TO_END', testID }); 63 | 64 | export const toExist = (testID: string) => 65 | sendEvent({ type: 'LAYOUT', action: 'EXISTS', testID }); 66 | 67 | export const reload = async () => { 68 | const args = (global as any).OWL_CLI_ARGS as CliRunOptions; 69 | 70 | if (!args) { 71 | return; 72 | } 73 | 74 | const config = await getConfig(args.config); 75 | 76 | if (args.platform === 'ios') { 77 | if (!config.ios?.device) { 78 | return Promise.reject('Missing device name'); 79 | } 80 | 81 | await xcrunTerminate({ 82 | debug: config.debug, 83 | binaryPath: config.ios?.binaryPath, 84 | device: config.ios.device, 85 | scheme: config.ios?.scheme, 86 | configuration: config.ios?.configuration, 87 | }); 88 | 89 | await xcrunLaunch({ 90 | debug: config.debug, 91 | binaryPath: config.ios?.binaryPath, 92 | device: config.ios.device, 93 | scheme: config.ios?.scheme, 94 | configuration: config.ios?.configuration, 95 | }); 96 | 97 | await waitFor(1000); 98 | 99 | await xcrunUi({ 100 | debug: config.debug, 101 | device: config.ios.device, 102 | configuration: config.ios.configuration, 103 | binaryPath: config.ios.binaryPath, 104 | }); 105 | } 106 | 107 | if (args.platform === 'android') { 108 | if (!config.android?.packageName) { 109 | return Promise.reject('Missing package name'); 110 | } 111 | 112 | await adbTerminate({ 113 | debug: config.debug, 114 | packageName: config.android.packageName, 115 | }); 116 | 117 | await adbLaunch({ 118 | debug: config.debug, 119 | packageName: config.android.packageName, 120 | }); 121 | 122 | await waitFor(1000); 123 | } 124 | }; 125 | -------------------------------------------------------------------------------- /.github/workflows/demo-app.yml: -------------------------------------------------------------------------------- 1 | name: Demo App 2 | 3 | on: [pull_request] 4 | 5 | jobs: 6 | run-visual-regression-ios: 7 | runs-on: macos-14 8 | 9 | steps: 10 | - uses: actions/checkout@v4 11 | 12 | - uses: actions/setup-node@v4 13 | with: 14 | node-version: 20 15 | cache: 'yarn' 16 | 17 | - name: Get Runner Information 18 | run: /usr/bin/xcodebuild -version 19 | 20 | - name: Install Dependencies (Library) 21 | run: yarn install --frozen-lockfile 22 | working-directory: ./ 23 | 24 | - name: Compile the library 25 | run: yarn build 26 | working-directory: ./ 27 | 28 | - name: Install Dependencies (Example App) 29 | run: yarn install --frozen-lockfile 30 | working-directory: ./example 31 | 32 | - name: Install CocoaPods Gem 33 | run: gem install cocoapods -v 1.11.0 34 | 35 | - uses: actions/cache@v4 36 | with: 37 | path: ./example/ios/Pods 38 | key: ${{ runner.os }}-pods-${{ hashFiles('**/Podfile.lock') }} 39 | restore-keys: | 40 | ${{ runner.os }}-pods- 41 | 42 | - name: Install Pods 43 | run: pod install 44 | working-directory: ./example/ios 45 | 46 | - uses: futureware-tech/simulator-action@v4 47 | with: 48 | model: 'iPhone 15 Pro' 49 | os_version: '18.1' 50 | erase_before_boot: true 51 | wait_for_boot: true 52 | shutdown_after_job: true 53 | 54 | - name: Run Owl Build 55 | run: yarn owl:build:ios 56 | working-directory: ./example 57 | 58 | - name: Run Owl Test 59 | run: yarn owl:test:ios 60 | working-directory: ./example 61 | 62 | - name: Store screenshots and report as artifacts 63 | uses: actions/upload-artifact@v4 64 | if: failure() 65 | with: 66 | name: owl-results 67 | path: example/.owl 68 | 69 | run-visual-regression-android: 70 | runs-on: macos-14 71 | if: ${{ false }} 72 | 73 | steps: 74 | - uses: actions/checkout@v4 75 | 76 | - uses: actions/setup-node@v4 77 | with: 78 | node-version: 20 79 | cache: 'yarn' 80 | 81 | - name: Install Dependencies (Library) 82 | run: yarn install --frozen-lockfile 83 | working-directory: ./ 84 | 85 | - name: Compile the library 86 | run: yarn build 87 | working-directory: ./ 88 | 89 | - name: Install Dependencies (Example App) 90 | run: yarn install --frozen-lockfile 91 | working-directory: ./example 92 | 93 | - uses: actions/cache@v4 94 | with: 95 | path: | 96 | ~/.gradle/caches 97 | ~/.gradle/wrapper 98 | key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} 99 | restore-keys: | 100 | ${{ runner.os }}-gradle- 101 | 102 | - name: SKDs - download required images 103 | run: $ANDROID_HOME/tools/bin/sdkmanager "system-images;android-30;default;x86_64" 104 | 105 | - name: SDKs - accept licenses 106 | run: y | $ANDROID_HOME/tools/bin/sdkmanager --licenses 107 | 108 | - name: Emulator - Create 109 | run: $ANDROID_HOME/tools/bin/avdmanager create avd -n Pixel_API_30 --device 'Nexus 5X' --package "system-images;android-30;default;x86_64" --sdcard 512M 110 | 111 | - name: Emulator - Boot 112 | run: $ANDROID_HOME/emulator/emulator -memory 4096 -avd Pixel_API_30 -wipe-data -no-window -gpu swiftshader_indirect -no-snapshot -noaudio -no-boot-anim & 113 | 114 | - name: ADB Wait For Device 115 | run: adb wait-for-any-device 116 | timeout-minutes: 3 117 | 118 | - name: Run Owl Build 119 | run: yarn owl:build:android 120 | working-directory: ./example 121 | 122 | - name: Run Owl Test 123 | run: yarn owl:test:android 124 | working-directory: ./example 125 | 126 | - name: Store screenshots as artifacts 127 | uses: actions/upload-artifact@v4 128 | if: failure() 129 | with: 130 | name: owl-screenshots 131 | path: example/.owl 132 | -------------------------------------------------------------------------------- /example/ios/OwlDemo/LaunchScreen.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 24 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /docs/ci/github-actions.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 1 3 | --- 4 | 5 | # GitHub Actions 6 | 7 | :::info 8 | 9 | With visual regression testing, it is all about **consistency**. Please make sure that you use the same simulator across environments. Use the same emulator configuration to generate the baseline images and for running the test suite on CI so that the library can compare the screenshots. The library will not be able to compare different sizes and resolutions of screenshots. 10 | 11 | ::: 12 | 13 | ### iOS 14 | 15 | To run the tests on an iOS simulator, you will need to use a [macOS based runner](https://docs.github.com/en/actions/using-github-hosted-runners/about-github-hosted-runners#supported-runners-and-hardware-resources). 16 | 17 | ```yaml title=".github/workflows/visual-regression-ios.yml" 18 | name: Visual Regression - iOS 19 | 20 | on: [pull_request] 21 | 22 | jobs: 23 | run-visual-regression-ios: 24 | runs-on: macos-14 25 | 26 | steps: 27 | - uses: actions/checkout@v4 28 | 29 | - name: Get Runner Information 30 | run: /usr/bin/xcodebuild -version 31 | 32 | - name: Set up Node 33 | uses: actions/setup-node@v4 34 | with: 35 | node-version: 20 36 | cache: 'yarn' 37 | 38 | - name: Install Dependencies 39 | run: yarn install --frozen-lockfile 40 | 41 | - name: Install CocoaPods 42 | run: gem install cocoapods -v 1.11.0 43 | 44 | - uses: actions/cache@v4 45 | with: 46 | path: ./ios/Pods 47 | key: ${{ runner.os }}-pods-${{ hashFiles('**/Podfile.lock') }} 48 | restore-keys: | 49 | ${{ runner.os }}-pods- 50 | 51 | - name: Install CocoaPods 52 | run: pod install 53 | working-directory: ./ios 54 | 55 | - uses: futureware-tech/simulator-action@v1 56 | with: 57 | model: 'iPhone 15 Pro' 58 | os_version: '18.1' 59 | 60 | - name: Run Owl Build 61 | run: yarn owl:build:ios 62 | 63 | - name: Run Owl Test 64 | run: yarn owl:test:ios 65 | 66 | - name: Store screenshots and report as artifacts 67 | uses: actions/upload-artifact@v4 68 | if: failure() 69 | with: 70 | name: owl-results 71 | path: ./.owl 72 | ``` 73 | 74 | ### Android 75 | 76 | ```yaml title=".github/workflows/visual-regression-android.yml" 77 | name: Visual Regression - Android 78 | 79 | on: [pull_request] 80 | 81 | jobs: 82 | run-visual-regression-android: 83 | runs-on: macos-14 84 | 85 | steps: 86 | - uses: actions/checkout@v4 87 | 88 | - name: Set up Node 89 | uses: actions/setup-node@v4 90 | with: 91 | node-version: 20 92 | cache: 'yarn' 93 | 94 | - name: Install Dependencies 95 | run: yarn install --frozen-lockfile 96 | 97 | - uses: actions/cache@v4 98 | with: 99 | path: | 100 | ~/.gradle/caches 101 | ~/.gradle/wrapper 102 | key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} 103 | restore-keys: | 104 | ${{ runner.os }}-gradle- 105 | 106 | - name: SKDs - download required images 107 | run: $ANDROID_HOME/tools/bin/sdkmanager "system-images;android-30;default;x86_64" 108 | 109 | - name: SDKs - accept licenses 110 | run: y | $ANDROID_HOME/tools/bin/sdkmanager --licenses 111 | 112 | - name: Emulator - Create 113 | run: $ANDROID_HOME/tools/bin/avdmanager create avd -n Pixel_API_30 --device 'Nexus 5X' --package "system-images;android-30;default;x86_64" --sdcard 512M 114 | 115 | - name: Emulator - Boot 116 | run: $ANDROID_HOME/emulator/emulator -memory 4096 -avd Pixel_API_30 -wipe-data -no-window -gpu swiftshader_indirect -no-snapshot -noaudio -no-boot-anim & 117 | 118 | - name: ADB Wait For Device 119 | run: adb wait-for-any-device 120 | timeout-minutes: 3 121 | 122 | - name: Run Owl Build 123 | run: yarn owl:build:android 124 | 125 | - name: Run Owl Test 126 | run: yarn owl:test:android 127 | 128 | - name: Store screenshots as artifacts 129 | uses: actions/upload-artifact@v4 130 | if: failure() 131 | with: 132 | name: owl-screenshots 133 | path: ./.owl 134 | ``` 135 | -------------------------------------------------------------------------------- /.github/workflows/demo-expo-app.yml: -------------------------------------------------------------------------------- 1 | name: Demo Expo App 2 | 3 | on: [pull_request] 4 | 5 | jobs: 6 | run-visual-regression-ios: 7 | runs-on: macos-11 8 | # Disable the Expo example test for now, as I'm getting a XCode 65 error which I havent been able to resolve yet. 9 | # The tests do run locally, so I'm not sure what the issue is. 10 | if: ${{ false }} 11 | 12 | steps: 13 | - uses: actions/setup-node@v4 14 | with: 15 | node-version: 20 16 | cache: 'yarn' 17 | 18 | - uses: actions/checkout@v4 19 | 20 | - name: Get Runner Information 21 | run: /usr/bin/xcodebuild -version 22 | 23 | - name: Install Dependencies (Library) 24 | run: yarn install --frozen-lockfile 25 | working-directory: ./ 26 | 27 | - name: Compile the library 28 | run: yarn build 29 | working-directory: ./ 30 | 31 | - name: Install Dependencies (Example Expo App) 32 | run: yarn install --frozen-lockfile 33 | working-directory: ./ExpoExample 34 | 35 | - name: Install CocoaPods Gem 36 | run: gem install cocoapods -v 1.11.0 37 | 38 | - uses: actions/cache@v4 39 | with: 40 | path: ./example/ios/Pods 41 | key: ${{ runner.os }}-expo-pods-${{ hashFiles('**/Podfile.lock') }} 42 | restore-keys: | 43 | ${{ runner.os }}-expo-pods- 44 | 45 | - name: Expo prebuild 46 | run: yarn expo prebuild --platform ios 47 | working-directory: ./ExpoExample 48 | 49 | - uses: futureware-tech/simulator-action@v1 50 | with: 51 | model: 'iPhone 13 Pro' 52 | os_version: '>=15.0' 53 | 54 | - name: Run Owl Build 55 | run: yarn owl:build:ios 56 | working-directory: ./ExpoExample 57 | 58 | - name: Run Owl Test 59 | run: yarn owl:test:ios 60 | working-directory: ./ExpoExample 61 | 62 | - name: Store screenshots and report as artifacts 63 | uses: actions/upload-artifact@v2 64 | if: failure() 65 | with: 66 | name: owl-results 67 | path: ExpoExample/.owl 68 | 69 | run-visual-regression-android: 70 | runs-on: macos-11 71 | if: ${{ false }} 72 | 73 | steps: 74 | - uses: actions/checkout@v4 75 | 76 | - uses: actions/setup-node@v4 77 | with: 78 | node-version: 20 79 | cache: 'yarn' 80 | 81 | - name: Install Dependencies (Library) 82 | run: yarn install --frozen-lockfile 83 | working-directory: ./ 84 | 85 | - name: Compile the library 86 | run: yarn build 87 | working-directory: ./ 88 | 89 | - name: Install Dependencies (Example Expo App) 90 | run: yarn install --frozen-lockfile 91 | working-directory: ./ExpoExample 92 | 93 | - name: Expo prebuild 94 | run: yarn expo prebuild --platform android 95 | working-directory: ./ExpoExample 96 | 97 | - uses: actions/cache@v4 98 | with: 99 | path: | 100 | ~/.gradle/caches 101 | ~/.gradle/wrapper 102 | key: ${{ runner.os }}-expo-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} 103 | restore-keys: | 104 | ${{ runner.os }}-expo-gradle- 105 | 106 | - name: SKDs - download required images 107 | run: $ANDROID_HOME/tools/bin/sdkmanager "system-images;android-30;default;x86_64" 108 | 109 | - name: SDKs - accept licenses 110 | run: y | $ANDROID_HOME/tools/bin/sdkmanager --licenses 111 | 112 | - name: Emulator - Create 113 | run: $ANDROID_HOME/tools/bin/avdmanager create avd -n Pixel_API_30 --device 'Nexus 5X' --package "system-images;android-30;default;x86_64" --sdcard 512M 114 | 115 | - name: Emulator - Boot 116 | run: $ANDROID_HOME/emulator/emulator -memory 4096 -avd Pixel_API_30 -wipe-data -no-window -gpu swiftshader_indirect -no-snapshot -noaudio -no-boot-anim & 117 | 118 | - name: ADB Wait For Device 119 | run: adb wait-for-any-device 120 | timeout-minutes: 3 121 | 122 | - name: Run Owl Build 123 | run: yarn owl:build:android 124 | working-directory: ./ExpoExample 125 | 126 | - name: Run Owl Test 127 | run: yarn owl:test:android 128 | working-directory: ./ExpoExample 129 | 130 | - name: Store screenshots as artifacts 131 | uses: actions/upload-artifact@v2 132 | if: failure() 133 | with: 134 | name: owl-screenshots 135 | path: ExpoExample/.owl 136 | -------------------------------------------------------------------------------- /docs/introduction/getting-started.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 1 3 | --- 4 | 5 | import Tabs from '@theme/Tabs'; 6 | import TabItem from '@theme/TabItem'; 7 | 8 | # Getting Started 9 | 10 | ### React Native Version Support 11 | 12 | React Native OWL currently supports React Native versions up to 0.70.x. 13 | 14 | ### Installation 15 | 16 | Install react-native-owl using either `yarn` or `npm`: 17 | 18 | ```bash npm2yarn 19 | npm install --save-dev react-native-owl 20 | ``` 21 | 22 | ### Configuration 23 | 24 | Create a file called `owl.config.json` in the root of your project, next to your `package.json`. There you will have to specify your settings for **iOS** and **Android**. For more information on the config file, please refer to the [configuration file](/docs/introduction/config-file) documentation. 25 | 26 | Below you can find an example config (can also be found in the [example app](https://github.com/FormidableLabs/react-native-owl/tree/main/example) of the repository). 27 | 28 | ```json title="owl.config.json" 29 | { 30 | "ios": { 31 | "workspace": "ios/OwlDemo.xcworkspace", 32 | "scheme": "OwlDemo", 33 | "configuration": "Release", 34 | "device": "iPhone 13 Pro" 35 | }, 36 | "android": { 37 | "packageName": "com.owldemo" 38 | } 39 | } 40 | ``` 41 | 42 | #### ts-jest 43 | 44 | You'll also need to add the [`ts-jest` preset](https://huafu.github.io/ts-jest/user/config/) to your Jest config if you plan to 45 | write your tests in Typescript: 46 | 47 | ```bash npm2yarn 48 | npm install --save-dev ts-jest 49 | ``` 50 | 51 | Then update your Jest config to use the `ts-jest` preset: 52 | ```json title="jest.config.js" 53 | "preset": "ts-jest" 54 | ``` 55 | or 56 | ```json title="package.json" 57 | "jest": { 58 | "preset": "ts-jest" 59 | } 60 | ``` 61 | 62 | ### Add tests 63 | 64 | Use the [takeScreenshot](/docs/api/methods#takescreenshotname-string) and [.toMatchBaseline](/docs/api/matchers) apis to implement screenshot tests. File names must end in `.owl.ts`, `.owl.tsx`, `.owl.js` or `.owl.jsx`. [See the example app](https://github.com/FormidableLabs/react-native-owl/tree/main/example) for a more complete example. 65 | 66 | #### Example 67 | 68 | ```js title="app.owl.tsx" 69 | import { press, takeScreenshot } from 'react-native-owl'; 70 | 71 | describe('App.tsx', () => { 72 | it('takes a screenshot of the first screen', async () => { 73 | const screen = await takeScreenshot('homescreen'); 74 | 75 | expect(screen).toMatchBaseline(); 76 | }); 77 | 78 | it('presses a button, then takes a screenshot', async () => { 79 | await press('button') 80 | 81 | const screen = await takeScreenshot('afterButtonPress'); 82 | 83 | expect(screen).toMatchBaseline(); 84 | }); 85 | }); 86 | ``` 87 | 88 | ### Building the app 89 | 90 | Before the app can be tested, it must be built. 91 | 92 | 93 | 94 | 95 | ```bash 96 | npx owl build --platform ios 97 | ``` 98 | 99 | 100 | 101 | 102 | ```bash 103 | yarn owl build --platform ios 104 | ``` 105 | 106 | 107 | 108 | 109 | :::info 110 | 111 | You will need to manually start the correct simulator before the tests are run. 112 | 113 | ::: 114 | 115 | 116 | This runs the app on the simulator, either comparing screenshots with the baseline images, or updating the baseline images. 117 | 118 | When comparing images, any difference in the current vs baseline will fail the test. 119 | 120 | #### Examples 121 | 122 | Test against the baseline images (will create the baseline images if they don't exist). 123 | 124 | 125 | 126 | 127 | ```bash 128 | npx owl test --platform ios 129 | ``` 130 | 131 | 132 | 133 | 134 | ```bash 135 | yarn owl test --platform ios 136 | ``` 137 | 138 | 139 | 140 | 141 | Update the baseline images 142 | 143 | 144 | 145 | 146 | ```bash 147 | npx owl test --platform ios --update 148 | ``` 149 | 150 | 151 | 152 | 153 | ```bash 154 | yarn owl test --platform ios --update 155 | ``` 156 | 157 | 158 | 159 | 160 | ### Failed tests report 161 | 162 | When the tests have failed any [`.toMatchBaseline()`](/docs/api/matchers) expectations, a [report is generated](/docs/cli/testing-the-app#viewing-the-report), where you can view all the screenshots, where the differences in the current vs baseline screenshots will be highlighted. 163 | -------------------------------------------------------------------------------- /lib/matchers.test.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import pixelmatch from 'pixelmatch'; 3 | import { mocked } from 'ts-jest/utils'; 4 | 5 | import { toMatchBaseline } from './matchers'; 6 | 7 | jest.mock('pixelmatch'); 8 | 9 | describe('matchers.ts', () => { 10 | const mockedPixelmatch = mocked(pixelmatch, true); 11 | 12 | const imageHello1Data = `iVBORw0KGgoAAAANSUhEUgAAACUAAAALCAYAAAD4OERFAAAABGdBTUEAALGPC/xhBQAAADhlWElmTU0AKgAAAAgAAYdpAAQAAAABAAAAGgAAAAAAAqACAAQAAAABAAAAJaADAAQAAAABAAAACwAAAADN8bJQAAABcElEQVQ4Ec2UvytGURjHr1/lHd5JlF9leMNAiQzKbqCMBt5ikUEWMhkMDGZ/gEHZDMqCsr6JQel9yW8Dg4lBJvL5ck7dTudw3UG+9el5nvM859znnnPujaIoKoMq+Ffqo5uTFB1dMacLuuEixXxNaYRNd26lO/BHcZbnjEEecu4zy82AjnARbuEIOsBqEqdkmLGDAav1luAB7mEBtLYrNdUKd27Cxjq+d1iFTtiALZCG4QYGQLlTGAXJd3zTjB9CM7SB6schpF4Sj76kmnoCu2v9+OemcBc7Z3yZKbAN+5o6Jj+hQiM1uWMDj/U2Ze+Utlu7Jb1A5tP7Om9NnDexvtIz4/tMC4MHscQ1fm0sTuTa3XkLVD8zrretM9RjByGkIommWFJHWIjFiVzbVKh4n8QIVBvWsLMQ0jaJPKheF3wI9uBX+qmpZVarAV12fSnyVyCkdRI9cAm65K/w3Z0inU56Y/1LRBJVUNQODUmKfTUfKJc7FJ+heOgAAAAASUVORK5CYII=`; 13 | const imageHello1Buffer = Buffer.from(imageHello1Data, 'base64'); 14 | 15 | const imageHello2Data = `iVBORw0KGgoAAAANSUhEUgAAACUAAAALCAYAAAD4OERFAAAABGdBTUEAALGPC/xhBQAAADhlWElmTU0AKgAAAAgAAYdpAAQAAAABAAAAGgAAAAAAAqACAAQAAAABAAAAJaADAAQAAAABAAAACwAAAADN8bJQAAABsklEQVQ4Ec2UOyhFcRzHj1d5lEEhogyKQqEMyioDxYJBCoMMsngslDvYDQyUYlJsHimURZJQSi6RV15ZxGD0+Hxv///pdDq3bnfhW5/7e/3/5/4fv3Mcx3ESIAX+lWpZzWkcK7pmTiVUw1Uc83uYswZL0ALuwSQT/IXa+dNxgzY3DbkwC06ifpCuMAR3cATlYNWLEzYM2GQUq+dNwDM8wRjo2X7Vk5iEBdiFOWgDV7q+b5iCCliEFZCa4RYaQLVz6AAp6Pr6yR9CIZSAxneBX/kkMjzJZfyQJ3a0qHewp1aHf2kGbGGHjC/TB3bBQYs6od6tgUZa5KYNAmw6uRm4B20kIttTOm6dlvQJaRHPcYqxNTBiYjXjhfGDTBHJA0/hBj/bE3vdMoJ1ULtUwRtEZE/nyyZ89oNYu80x5GEbIZrOKBR4irrCfU9sXW12FYahFdwF4btXJj9IOyT1pqQa5rGDEE3aeSdovBq8CbbBr1ES+hRsgK5QaI4r9ZT3O6WjfDDVLOwevMArqMcyQQrqKZ1SGB5B16xF2lbAdaXaj49jtxqDox3ruEUsSmJQKegNi0u/XtRShUjycDoAAAAASUVORK5CYII=`; 16 | const imageHello2Buffer = Buffer.from(imageHello2Data, 'base64'); 17 | 18 | const mkdirSyncMock = jest.spyOn(fs, 'mkdirSync').mockImplementation(); 19 | const readFileMock = jest.spyOn(fs, 'readFileSync'); 20 | const writeFileMock = jest.spyOn(fs, 'writeFileSync'); 21 | 22 | beforeAll(() => { 23 | process.env.OWL_PLATFORM = 'ios'; 24 | }); 25 | 26 | afterAll(() => { 27 | delete process.env.OWL_PLATFORM; 28 | }); 29 | 30 | describe('toMatchBaseline.ts', () => { 31 | beforeEach(() => { 32 | mkdirSyncMock.mockReset(); 33 | readFileMock.mockReset(); 34 | writeFileMock.mockReset(); 35 | }); 36 | 37 | it('should compare two identical images', () => { 38 | readFileMock 39 | .mockReturnValueOnce(imageHello1Buffer) 40 | .mockReturnValueOnce(imageHello1Buffer); 41 | 42 | mockedPixelmatch.mockReturnValueOnce(0); 43 | 44 | const latestPath = 'latest/ios/screen.png'; 45 | 46 | const result = toMatchBaseline(latestPath); 47 | 48 | expect(result.message()).toBe( 49 | 'Compared screenshot to match baseline. No differences were found.' 50 | ); 51 | expect(result.pass).toBe(true); 52 | expect(writeFileMock).toHaveBeenCalledTimes(0); 53 | }); 54 | 55 | it('should compare two different images', () => { 56 | readFileMock 57 | .mockReturnValueOnce(imageHello1Buffer) 58 | .mockReturnValueOnce(imageHello2Buffer); 59 | 60 | mockedPixelmatch.mockReturnValueOnce(55); 61 | 62 | const latestPath = 'latest/ios/screen.png'; 63 | 64 | const result = toMatchBaseline(latestPath); 65 | 66 | expect(result.message()).toBe( 67 | 'Compared screenshot to match baseline. 55 were different.' 68 | ); 69 | expect(result.pass).toBe(false); 70 | expect(writeFileMock).toHaveBeenCalledTimes(1); 71 | }); 72 | 73 | it('should return early, skipping the comparison if the latestPath is the baseline path (fresh screenshot)', () => { 74 | const latestPath = 'baseline/ios/screen.png'; 75 | 76 | const result = toMatchBaseline(latestPath); 77 | 78 | expect(result.message()).toBe( 79 | 'Generated a fresh baseline, skipping comparison.' 80 | ); 81 | expect(result.pass).toBe(true); 82 | expect(writeFileMock).toHaveBeenCalledTimes(0); 83 | }); 84 | 85 | it('handles file diffing errors', () => { 86 | readFileMock.mockReturnValueOnce(imageHello1Buffer); 87 | 88 | const latestPath = 'latest/ios/screen.png'; 89 | 90 | const result = toMatchBaseline(latestPath); 91 | 92 | expect(result.message()).toBe( 93 | "Screenshot diffing error - Cannot read properties of undefined (reading 'length')" 94 | ); 95 | expect(result.pass).toBe(false); 96 | expect(writeFileMock).toHaveBeenCalledTimes(0); 97 | }); 98 | }); 99 | }); 100 | --------------------------------------------------------------------------------