From 078af78e30ca30c633a23de14c88ab43c92d7e15 Mon Sep 17 00:00:00 2001 From: JoseLion Date: Wed, 6 Mar 2024 14:36:04 -0500 Subject: [PATCH] feat(core): Allow to change native methods behavior --- package.json | 3 +- register.js | 2 +- src/helpers/mockComponent.ts | 8 +- ...kNativeMethods.ts => nativeMethodsMock.ts} | 5 +- src/lib/Components/ActivityIndicator.ts | 2 +- src/lib/Components/Image.ts | 23 ++++-- src/lib/Components/ScrollView.ts | 57 ++++++++++--- src/lib/Components/Text.ts | 4 +- src/lib/Components/TextInput.ts | 22 +++-- src/lib/Components/View.ts | 4 +- src/lib/mockNative.ts | 80 +++++++++++++++++++ src/main.ts | 40 ++-------- src/register.ts | 34 ++++++++ test/unit/lib/mockNative.test.tsx | 53 ++++++++++++ test/unit/main.test.tsx | 20 ----- test/unit/register.test.tsx | 41 ++++++++++ yarn.lock | 8 ++ 17 files changed, 319 insertions(+), 87 deletions(-) rename src/helpers/{mockNativeMethods.ts => nativeMethodsMock.ts} (58%) create mode 100644 src/lib/mockNative.ts create mode 100644 src/register.ts create mode 100644 test/unit/lib/mockNative.test.tsx delete mode 100644 test/unit/main.test.tsx create mode 100644 test/unit/register.test.tsx diff --git a/package.json b/package.json index a64cdda..b8f6074 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,8 @@ "@babel/register": "^7.23.7", "@react-native/babel-preset": "^0.73.21", "babel-plugin-module-resolver": "^5.0.0", - "dot-prop-immutable": "^2.1.1" + "dot-prop-immutable": "^2.1.1", + "ts-pattern": "^5.0.8" }, "devDependencies": { "@assertive-ts/core": "^2.1.0", diff --git a/register.js b/register.js index e1bdfa0..9567096 100644 --- a/register.js +++ b/register.js @@ -1,5 +1,5 @@ const start = Date.now(); -require("./dist/main"); +require("./dist/register"); const end = Date.now(); const diff = (end - start) / 1000; diff --git a/src/helpers/mockComponent.ts b/src/helpers/mockComponent.ts index 6f2b40e..23706eb 100644 --- a/src/helpers/mockComponent.ts +++ b/src/helpers/mockComponent.ts @@ -6,10 +6,16 @@ import { ReactNode, createElement, } from "react"; +import type { NativeMethods } from "react-native"; + +import type { ScrollViewMethods } from "../lib/Components/ScrollView"; +import type { TextInputMethods } from "../lib/Components/TextInput"; + +export type AllNativeMethods = NativeMethods | ScrollViewMethods | TextInputMethods; export function mockComponent>>( RealComponent: C, - instanceMethods?: object | null, + instanceMethods?: AllNativeMethods, ): C { const SuperClass: ComponentClass> = typeof RealComponent === "function" ? RealComponent diff --git a/src/helpers/mockNativeMethods.ts b/src/helpers/nativeMethodsMock.ts similarity index 58% rename from src/helpers/mockNativeMethods.ts rename to src/helpers/nativeMethodsMock.ts index 0f68a49..6986381 100644 --- a/src/helpers/mockNativeMethods.ts +++ b/src/helpers/nativeMethodsMock.ts @@ -1,10 +1,13 @@ +import { NativeMethods } from "react-native"; + import { noop } from "./commons"; -export const MockNativeMethods = { +export const nativeMethodsMock: NativeMethods = { blur: noop, focus: noop, measure: noop, measureInWindow: noop, measureLayout: noop, + refs: { }, setNativeProps: noop, }; diff --git a/src/lib/Components/ActivityIndicator.ts b/src/lib/Components/ActivityIndicator.ts index 8463563..932730b 100644 --- a/src/lib/Components/ActivityIndicator.ts +++ b/src/lib/Components/ActivityIndicator.ts @@ -2,4 +2,4 @@ import { ActivityIndicator } from "react-native"; import { mockComponent } from "../../helpers/mockComponent"; -export const ActivityIndicatorMock = mockComponent(ActivityIndicator, null); +export const ActivityIndicatorMock = mockComponent(ActivityIndicator); diff --git a/src/lib/Components/Image.ts b/src/lib/Components/Image.ts index 53d14e4..1236ca6 100644 --- a/src/lib/Components/Image.ts +++ b/src/lib/Components/Image.ts @@ -4,13 +4,22 @@ import { Image } from "react-native"; import { noop } from "../../helpers/commons"; import { mockComponent } from "../../helpers/mockComponent"; -const Mock = mockComponent(Image as ComponentClass); +export type ImageMethods = Partial; -export const ImageMock = Object.assign(Mock, { +export const imageMethodsMock: ImageMethods = { getSize: noop, getSizeWithHeaders: noop, - prefetch: noop, - prefetchWithMetadata: noop, - queryCache: noop, - resolveAssetSource: noop, -}); + prefetch: () => Promise.resolve(false), + prefetchWithMetadata: () => Promise.resolve(false), + queryCache: () => Promise.resolve({ }), + resolveAssetSource: () => ({ + height: 0, + scale: 0, + uri: "", + width: 0, + }), +}; + +const Mock = mockComponent(Image as ComponentClass); + +export const ImageMock = Object.assign(Mock, imageMethodsMock); diff --git a/src/lib/Components/ScrollView.ts b/src/lib/Components/ScrollView.ts index 8c4b595..26d3407 100644 --- a/src/lib/Components/ScrollView.ts +++ b/src/lib/Components/ScrollView.ts @@ -1,25 +1,62 @@ /* eslint-disable sort-keys */ -import { PropsWithChildren, ReactNode, createElement } from "react"; -import { ScrollView, View, requireNativeComponent } from "react-native"; +import { ElementRef, PropsWithChildren, ReactNode, createElement } from "react"; +import { HostComponent, NativeMethods, ScrollView, View, requireNativeComponent } from "react-native"; import { noop } from "../../helpers/commons"; import { mockComponent } from "../../helpers/mockComponent"; -import { MockNativeMethods } from "../../helpers/mockNativeMethods"; +import { nativeMethodsMock } from "../../helpers/nativeMethodsMock"; -const RCTScrollView = requireNativeComponent("RCTScrollView"); -const BaseMock = mockComponent(ScrollView, { - ...MockNativeMethods, - getScrollResponder: noop, +export type ScrollViewMethods = NativeMethods | ScrollView & { + getInnerViewRef: () => ElementRef | null; + getNativeScrollRef: () => ElementRef> | null; +}; + +export const scrollViewMethodsMock: ScrollViewMethods = { + ...nativeMethodsMock, + getScrollResponder: () => ({ + addListenerOn: noop, + componentWillMount: noop, + scrollResponderGetScrollableNode: noop, + scrollResponderHandleMomentumScrollBegin: noop, + scrollResponderHandleMomentumScrollEnd: noop, + scrollResponderHandleResponderGrant: noop, + scrollResponderHandleResponderReject: noop, + scrollResponderHandleResponderRelease: noop, + scrollResponderHandleScroll: noop, + scrollResponderHandleScrollBeginDrag: noop, + scrollResponderHandleScrollEndDrag: noop, + scrollResponderHandleScrollShouldSetResponder: () => false, + scrollResponderHandleStartShouldSetResponder: () => false, + scrollResponderHandleStartShouldSetResponderCapture: () => false, + scrollResponderHandleTerminationRequest: () => false, + scrollResponderHandleTouchEnd: noop, + scrollResponderHandleTouchMove: noop, + scrollResponderHandleTouchStart: noop, + scrollResponderInputMeasureAndScrollToKeyboard: noop, + scrollResponderIsAnimating: () => false, + scrollResponderKeyboardDidHide: noop, + scrollResponderKeyboardDidShow: noop, + scrollResponderKeyboardWillHide: noop, + scrollResponderKeyboardWillShow: noop, + scrollResponderScrollNativeHandleToKeyboard: noop, + scrollResponderScrollTo: noop, + scrollResponderTextInputFocusError: noop, + scrollResponderZoomTo: noop, + }), getScrollableNode: noop, getInnerViewNode: noop, - getInnerViewRef: noop, - getNativeScrollRef: noop, + getInnerViewRef: () => null, + getNativeScrollRef: () => null, scrollTo: noop, scrollToEnd: noop, flashScrollIndicators: noop, scrollResponderZoomTo: noop, scrollResponderScrollNativeHandleToKeyboard: noop, -}); + +}; + +const RCTScrollView = requireNativeComponent("RCTScrollView"); +const BaseMock = mockComponent(ScrollView, scrollViewMethodsMock); export class ScrollViewMock

extends BaseMock { diff --git a/src/lib/Components/Text.ts b/src/lib/Components/Text.ts index 2aaa993..5042776 100644 --- a/src/lib/Components/Text.ts +++ b/src/lib/Components/Text.ts @@ -1,6 +1,6 @@ import { Text } from "react-native"; import { mockComponent } from "../../helpers/mockComponent"; -import { MockNativeMethods } from "../../helpers/mockNativeMethods"; +import { nativeMethodsMock } from "../../helpers/nativeMethodsMock"; -export const TextMock = mockComponent(Text, MockNativeMethods); +export const TextMock = mockComponent(Text, nativeMethodsMock); diff --git a/src/lib/Components/TextInput.ts b/src/lib/Components/TextInput.ts index 1ebba3e..1ce29f4 100644 --- a/src/lib/Components/TextInput.ts +++ b/src/lib/Components/TextInput.ts @@ -1,12 +1,20 @@ -import { TextInput } from "react-native"; +import { ElementRef } from "react"; +import { HostComponent, NativeMethods, TextInput } from "react-native"; import { noop } from "../../helpers/commons"; import { mockComponent } from "../../helpers/mockComponent"; -import { MockNativeMethods } from "../../helpers/mockNativeMethods"; +import { nativeMethodsMock } from "../../helpers/nativeMethodsMock"; -export const TextInputMock = mockComponent(TextInput, { - ...MockNativeMethods, +export type TextInputMethods = NativeMethods | TextInput & { + getNativeRef: () => ElementRef> | undefined; +}; + +export const textInputMethodsMock: TextInputMethods = { + ...nativeMethodsMock, clear: noop, - getNativeRef: noop, - isFocused: noop, -}); + getNativeRef: () => undefined, + isFocused: () => false, + setSelection: noop, +}; + +export const TextInputMock = mockComponent(TextInput, textInputMethodsMock); diff --git a/src/lib/Components/View.ts b/src/lib/Components/View.ts index 069ade7..59e9d5b 100644 --- a/src/lib/Components/View.ts +++ b/src/lib/Components/View.ts @@ -1,6 +1,6 @@ import { View } from "react-native"; import { mockComponent } from "../../helpers/mockComponent"; -import { MockNativeMethods } from "../../helpers/mockNativeMethods"; +import { nativeMethodsMock } from "../../helpers/nativeMethodsMock"; -export const ViewMock = mockComponent(View, MockNativeMethods); +export const ViewMock = mockComponent(View, nativeMethodsMock); diff --git a/src/lib/mockNative.ts b/src/lib/mockNative.ts new file mode 100644 index 0000000..e72f49d --- /dev/null +++ b/src/lib/mockNative.ts @@ -0,0 +1,80 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ +import type { ComponentClass, PropsWithChildren } from "react"; +import type { NativeMethods } from "react-native"; +import { match } from "ts-pattern"; + +import { replace } from "../helpers/commons"; +import { AllNativeMethods, mockComponent } from "../helpers/mockComponent"; +import { nativeMethodsMock } from "../helpers/nativeMethodsMock"; + +import { imageMethodsMock, ImageMethods } from "./Components/Image"; +import { ScrollViewMethods, scrollViewMethodsMock } from "./Components/ScrollView"; +import { TextInputMethods, textInputMethodsMock } from "./Components/TextInput"; + +export type NativeBase = + | "ActivityIndicator" + | "Modal" + | "Text" + | "View"; + +export type NativeKey = NativeBase + | "Image" + | "ScrollView" + | "TextInput"; + +const MOCKS: Set = new Set(); + +const PATHS: Record = { + ActivityIndicator: "react-native/Libraries/Components/ActivityIndicator/ActivityIndicator", + Image: "react-native/Libraries/Image/Image", + Modal: "react-native/Libraries/Modal/Modal", + ScrollView: "react-native/Libraries/Components/ScrollView/ScrollView", + Text: "react-native/Libraries/Text/Text", + TextInput: "react-native/Libraries/Components/TextInput/TextInput", + View: "react-native/Libraries/Components/View/View", +}; + +/** + * Allows you to change the behavior of native methods for certain components. + * You can restore all behaviors to their original mocks using + * {@link restoreNativeMocks} function. + * + * @param options type of component and native methods + */ +export function mockNative(type: "TextInput", methods: Partial): void; +export function mockNative(type: "ScrollView", methods: Partial): void; +export function mockNative(type: "Image", methods: Partial): void; +export function mockNative(type: NativeBase, methods: Partial): void; +export function mockNative(type: NativeKey, methods: Partial): void { + const path = PATHS[type]; + const Comp = require(path) as ComponentClass>; + + const Mock = match(type) + .with("Image", () => Object.assign(Comp, { ...imageMethodsMock, ...methods })) + .with("ScrollView", () => mockComponent(Comp, Object.assign({ }, scrollViewMethodsMock, methods))) + .with("TextInput", () => mockComponent(Comp, Object.assign({ }, textInputMethodsMock, methods))) + .otherwise(() => mockComponent(Comp, Object.assign({ }, nativeMethodsMock, methods))); + + replace(path, type === "ActivityIndicator" ? { default: Mock } : Mock); + MOCKS.add(type); +} + +/** + * Restore the native methods behavior off all native components to their + * original mocks. + */ +export function restoreNativeMocks(): void { + MOCKS.forEach(type => { + const path = PATHS[type]; + const Comp = require(path) as ComponentClass>; + const Mock = match(type) + .with("Image", () => Object.assign(Comp, imageMethodsMock)) + .with("ScrollView", () => mockComponent(Comp, scrollViewMethodsMock)) + .with("TextInput", () => mockComponent(Comp, textInputMethodsMock)) + .otherwise(() => mockComponent(Comp, nativeMethodsMock)); + + replace(path, type === "ActivityIndicator" ? { default: Mock } : Mock); + }); + + MOCKS.clear(); +} diff --git a/src/main.ts b/src/main.ts index 334404d..ee04199 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,34 +1,6 @@ -import "./lib/babelRegister"; -import "./lib/polyfills"; -import "./lib/coreMocks"; - -import { replace } from "./helpers/commons"; -import { AccessibilityInfoMock } from "./lib/Components/AccessibilityInfo"; -import { ActivityIndicatorMock } from "./lib/Components/ActivityIndicator"; -import { AppStateMock } from "./lib/Components/AppState"; -import { ClipboardMock } from "./lib/Components/Clipboard"; -import { ImageMock } from "./lib/Components/Image"; -import { LinkingMock } from "./lib/Components/Linking"; -import { ModalMock } from "./lib/Components/Modal"; -import { RefreshControlMock } from "./lib/Components/RefreshControl"; -import { ScrollViewMock } from "./lib/Components/ScrollView"; -import { TextMock } from "./lib/Components/Text"; -import { TextInputMock } from "./lib/Components/TextInput"; -import { VibrationMock } from "./lib/Components/Vibration"; -import { ViewMock } from "./lib/Components/View"; -import { ViewNativeComponentMock } from "./lib/Components/ViewNativeComponent"; - -replace("react-native/Libraries/Image/Image", ImageMock); -replace("react-native/Libraries/Text/Text", TextMock); -replace("react-native/Libraries/Components/TextInput/TextInput", TextInputMock); -replace("react-native/Libraries/Modal/Modal", ModalMock); -replace("react-native/Libraries/Components/AccessibilityInfo/AccessibilityInfo", { default: AccessibilityInfoMock }); -replace("react-native/Libraries/Components/Clipboard/Clipboard", ClipboardMock); -replace("react-native/Libraries/Components/RefreshControl/RefreshControl", RefreshControlMock); -replace("react-native/Libraries/Components/ScrollView/ScrollView", ScrollViewMock); -replace("react-native/Libraries/Components/ActivityIndicator/ActivityIndicator", { default: ActivityIndicatorMock }); -replace("react-native/Libraries/AppState/AppState", AppStateMock); -replace("react-native/Libraries/Linking/Linking", LinkingMock); -replace("react-native/Libraries/Vibration/Vibration", VibrationMock); -replace("react-native/Libraries/Components/View/View", ViewMock); -replace("react-native/Libraries/Components/View/ViewNativeComponent", ViewNativeComponentMock); +export { + NativeBase, + NativeKey, + mockNative, + restoreNativeMocks, +} from "./lib/mockNative"; diff --git a/src/register.ts b/src/register.ts new file mode 100644 index 0000000..334404d --- /dev/null +++ b/src/register.ts @@ -0,0 +1,34 @@ +import "./lib/babelRegister"; +import "./lib/polyfills"; +import "./lib/coreMocks"; + +import { replace } from "./helpers/commons"; +import { AccessibilityInfoMock } from "./lib/Components/AccessibilityInfo"; +import { ActivityIndicatorMock } from "./lib/Components/ActivityIndicator"; +import { AppStateMock } from "./lib/Components/AppState"; +import { ClipboardMock } from "./lib/Components/Clipboard"; +import { ImageMock } from "./lib/Components/Image"; +import { LinkingMock } from "./lib/Components/Linking"; +import { ModalMock } from "./lib/Components/Modal"; +import { RefreshControlMock } from "./lib/Components/RefreshControl"; +import { ScrollViewMock } from "./lib/Components/ScrollView"; +import { TextMock } from "./lib/Components/Text"; +import { TextInputMock } from "./lib/Components/TextInput"; +import { VibrationMock } from "./lib/Components/Vibration"; +import { ViewMock } from "./lib/Components/View"; +import { ViewNativeComponentMock } from "./lib/Components/ViewNativeComponent"; + +replace("react-native/Libraries/Image/Image", ImageMock); +replace("react-native/Libraries/Text/Text", TextMock); +replace("react-native/Libraries/Components/TextInput/TextInput", TextInputMock); +replace("react-native/Libraries/Modal/Modal", ModalMock); +replace("react-native/Libraries/Components/AccessibilityInfo/AccessibilityInfo", { default: AccessibilityInfoMock }); +replace("react-native/Libraries/Components/Clipboard/Clipboard", ClipboardMock); +replace("react-native/Libraries/Components/RefreshControl/RefreshControl", RefreshControlMock); +replace("react-native/Libraries/Components/ScrollView/ScrollView", ScrollViewMock); +replace("react-native/Libraries/Components/ActivityIndicator/ActivityIndicator", { default: ActivityIndicatorMock }); +replace("react-native/Libraries/AppState/AppState", AppStateMock); +replace("react-native/Libraries/Linking/Linking", LinkingMock); +replace("react-native/Libraries/Vibration/Vibration", VibrationMock); +replace("react-native/Libraries/Components/View/View", ViewMock); +replace("react-native/Libraries/Components/View/ViewNativeComponent", ViewNativeComponentMock); diff --git a/test/unit/lib/mockNative.test.tsx b/test/unit/lib/mockNative.test.tsx new file mode 100644 index 0000000..834bab8 --- /dev/null +++ b/test/unit/lib/mockNative.test.tsx @@ -0,0 +1,53 @@ +import "../../../src/register"; + +import { expect } from "@assertive-ts/core"; +import { render } from "@testing-library/react-native"; +import { ReactElement, useEffect, useRef, useState } from "react"; +import { Text, View } from "react-native"; + +import { mockNative, restoreNativeMocks } from "../../../src/lib/mockNative"; + +function TestScreen(): ReactElement { + + const [widthValue, setWidthValue] = useState(0); + const viewRef = useRef(null); + + useEffect(() => { + viewRef.current?.measure((_x, _y, width) => { + setWidthValue(width); + }); + }, []); + + return ( + + {`Measured width: ${widthValue}`} + + ); +} + +describe("[Unit] mockNative.test.tsx", () => { + afterEach(restoreNativeMocks); + + describe(".mockNative", () => { + it("change the behavior of native methods", () => { + mockNative("View", { measure: cb => cb(0, 0, 50, 0, 0, 0) }); + const { getByText } = render(); + + expect(getByText("Measured width: 50")).toBePresent(); + }); + }); + + describe(".restoreNativeMocks", () => { + it("restores the behavior of native methods to their original mocks", () => { + mockNative("View", { measure: cb => cb(0, 0, 100, 0, 0, 0) }); + const init = render(); + + expect(init.getByText("Measured width: 100")).toBePresent(); + + restoreNativeMocks(); + const next = render(); + + expect(next.getByText("Measured width: 0")).toBePresent(); + }); + }); +}); diff --git a/test/unit/main.test.tsx b/test/unit/main.test.tsx deleted file mode 100644 index eecb98d..0000000 --- a/test/unit/main.test.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import "../../src/main"; - -import { expect } from "@assertive-ts/core"; -import { render } from "@testing-library/react-native"; -import { Text, View } from "react-native"; - -describe("[Unit] main.test.ts", () => { - context("when main is called", () => { - it("mocks react native so it can render on Node.js", () => { - const { getByText } = render( - - {"Hello world!"} - , - ); - - expect(getByText("Hello world!")).toBePresent(); - expect(() => getByText("foo")).toThrowError(); - }); - }); -}); diff --git a/test/unit/register.test.tsx b/test/unit/register.test.tsx new file mode 100644 index 0000000..9c437f6 --- /dev/null +++ b/test/unit/register.test.tsx @@ -0,0 +1,41 @@ +import "../../src/register"; + +import { expect } from "@assertive-ts/core"; +import { render } from "@testing-library/react-native"; +import { ActivityIndicator, Image, Modal, ScrollView, Text, TextInput, View } from "react-native"; + +describe("[Unit] register.test.ts", () => { + context("when main is called", () => { + it("mocks react native so it can render on Node.js", () => { + const { + getByText, + getByPlaceholderText, + getByDisplayValue, + getByLabelText, + } = render( + + + + {"Hello world!"} + + Profile picture + + {"I'm on a modal"} + + + {"foo"} + + + , + ); + + expect(getByLabelText("Loading")).toBePresent(); + expect(getByText("Hello world!")).toBePresent(); + expect(getByPlaceholderText("Say hello here...")).toBePresent(); + expect(getByDisplayValue("Hello :)")).toBePresent(); + expect(getByLabelText("Profile picture")).toBePresent(); + expect(getByText("I'm on a modal")).toBePresent(); + expect(() => getByText("foo")).toThrowError(); + }); + }); +}); diff --git a/yarn.lock b/yarn.lock index b112428..6c1f9ea 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8881,6 +8881,7 @@ __metadata: semantic-release-yarn: "npm:^3.0.2" sinon: "npm:^17.0.1" ts-node: "npm:^10.9.2" + ts-pattern: "npm:^5.0.8" tslib: "npm:^2.6.2" typescript: "npm:^5.3.3" peerDependencies: @@ -10376,6 +10377,13 @@ __metadata: languageName: node linkType: hard +"ts-pattern@npm:^5.0.8": + version: 5.0.8 + resolution: "ts-pattern@npm:5.0.8" + checksum: 10/a57f7def89c0fae3065d56a1fb94510688910b4b99610a41b2b3df277d92af17c26a4a65717e7d7c3ac085258d20144f2f5a748d860c043609aea29cff7a43ab + languageName: node + linkType: hard + "tsconfig-paths@npm:^3.15.0": version: 3.15.0 resolution: "tsconfig-paths@npm:3.15.0"