=>
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 | [](https://commerce.nearform.com/open-source/react-native-owl/)
2 |
3 | `react-native-owl` — Visual regression testing for React Native
4 |
5 | 
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 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
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 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
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 |
--------------------------------------------------------------------------------