diff --git a/__mocks__/react-native.ts b/__mocks__/react-native.ts index 482964d2..34cb204a 100644 --- a/__mocks__/react-native.ts +++ b/__mocks__/react-native.ts @@ -32,7 +32,15 @@ function createMockEmitter() { const requireNativeComponent = (..._args: any[]) => { const React = require('react'); - return (props: any) => React.createElement('View', props); + return (props: any) => + React.createElement('View', { + ...props, + testID: props?.testID ?? 'accelerated-checkout-buttons', + }); +}; + +const StyleSheet = { + flatten: jest.fn(style => style), }; const exampleConfig = {preloading: true}; @@ -66,4 +74,5 @@ module.exports = { eventEmitter: createMockEmitter(), }, }, + StyleSheet, }; diff --git a/modules/@shopify/checkout-sheet-kit/tests/AcceleratedCheckoutButtons.test.tsx b/modules/@shopify/checkout-sheet-kit/tests/AcceleratedCheckoutButtons.test.tsx index b35e8bf7..fccf2e46 100644 --- a/modules/@shopify/checkout-sheet-kit/tests/AcceleratedCheckoutButtons.test.tsx +++ b/modules/@shopify/checkout-sheet-kit/tests/AcceleratedCheckoutButtons.test.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import renderer from 'react-test-renderer'; +import {render, act} from '@testing-library/react-native'; import {Platform} from 'react-native'; import { AcceleratedCheckoutButtons, @@ -7,43 +7,7 @@ import { RenderState, } from '../src'; -// Mock react-native Platform and requireNativeComponent -jest.mock('react-native', () => { - const mockRequireNativeComponent = jest.fn().mockImplementation(() => { - const mockComponent = (props: any) => { - // Use React.createElement with plain object instead - const mockReact = jest.requireActual('react'); - return mockReact.createElement('View', props); - }; - return mockComponent; - }); - - const mockShopifyCheckoutSheetKit = { - version: '0.7.0', - preload: jest.fn(), - present: jest.fn(), - invalidateCache: jest.fn(), - getConfig: jest.fn(async () => ({preloading: true})), - setConfig: jest.fn(), - addEventListener: jest.fn(), - removeEventListeners: jest.fn(), - initiateGeolocationRequest: jest.fn(), - configureAcceleratedCheckouts: jest.fn(), - isAcceleratedCheckoutAvailable: jest.fn(), - }; - - return { - Platform: { - OS: 'ios', - Version: '16.0', - }, - requireNativeComponent: mockRequireNativeComponent, - NativeModules: { - ShopifyCheckoutSheetKit: mockShopifyCheckoutSheetKit, - }, - NativeEventEmitter: jest.fn(), - }; -}); +jest.mock('react-native'); const mockLog = jest.fn(); // Silence console.error @@ -71,101 +35,64 @@ describe('AcceleratedCheckoutButtons', () => { }); describe('iOS Version Compatibility', () => { - it('returns null on iOS versions below 16', () => { - (Platform as any).Version = '15.5'; - - const component = renderer.create( - , - ); - - expect(component.toJSON()).toBeNull(); - }); - - it('returns null on iOS 14', () => { - (Platform as any).Version = '14.0'; - - const component = renderer.create( - , - ); - - expect(component.toJSON()).toBeNull(); - }); - - it('renders on iOS 16', () => { - (Platform as any).Version = '16.0'; - - const component = renderer.create( - , - ); - - expect(component.toJSON()).toBeTruthy(); - }); - - it('renders on iOS 17', () => { - (Platform as any).Version = '17.0'; - - const component = renderer.create( - , - ); - - expect(component.toJSON()).toBeTruthy(); - }); - - it('handles iOS version with decimal correctly', () => { - (Platform as any).Version = '16.4.1'; + it.each(['16.0', '17.0', '16.4.1'])('renders on iOS %s', version => { + (Platform as any).Version = version; - const component = renderer.create( + const {getByTestId} = render( , ); - expect(component.toJSON()).toBeTruthy(); + expect(getByTestId('accelerated-checkout-buttons')).toBeTruthy(); }); - it('does not warn when returning null for iOS < 16', () => { - (Platform as any).Version = '15.0'; + it.each(['15.5', '14.0', '15.0'])( + 'does not render on iOS %s', + version => { + (Platform as any).Version = version; - const component = renderer.create( - , - ); + const {queryByTestId} = render( + , + ); - expect(component.toJSON()).toBeNull(); - expect(mockLog).not.toHaveBeenCalled(); - expect(mockError).not.toHaveBeenCalled(); - }); + expect(queryByTestId('accelerated-checkout-buttons')).toBeNull(); + expect(mockLog).not.toHaveBeenCalled(); + expect(mockError).not.toHaveBeenCalled(); + }, + ); }); it('renders without crashing with cartId', () => { - const component = renderer.create( + const {getByTestId} = render( , ); - expect(component.toJSON()).toBeTruthy(); + expect(getByTestId('accelerated-checkout-buttons')).toBeTruthy(); }); it('renders without crashing with variant', () => { - const component = renderer.create( + const {getByTestId} = render( , ); - expect(component.toJSON()).toBeTruthy(); + expect(getByTestId('accelerated-checkout-buttons')).toBeTruthy(); }); it('renders without crashing with variant and quantity', () => { - const component = renderer.create( + const {getByTestId} = render( , ); - expect(component.toJSON()).toBeTruthy(); + expect(getByTestId('accelerated-checkout-buttons')).toBeTruthy(); }); it('passes through props to native component', () => { - const component = renderer.create( + const {getByTestId} = render( { />, ); - const tree = component.toJSON(); - expect(tree).toBeTruthy(); - // @ts-expect-error tree is not null based on check above - expect(tree.props.checkoutIdentifier).toEqual({ + const nativeComponent = getByTestId('accelerated-checkout-buttons'); + expect(nativeComponent).toBeTruthy(); + expect(nativeComponent.props.checkoutIdentifier).toEqual({ cartId: 'gid://shopify/Cart/123', }); - // @ts-expect-error tree is not null based on check above - expect(tree.props.cornerRadius).toBe(12); - // @ts-expect-error tree is not null based on check above - expect(tree.props.wallets).toEqual([AcceleratedCheckoutWallet.shopPay]); + expect(nativeComponent.props.cornerRadius).toBe(12); + expect(nativeComponent.props.wallets).toEqual([ + AcceleratedCheckoutWallet.shopPay, + ]); }); - it('logs and returns null when neither cartId nor variantId is provided', () => { - expect(() => { - renderer.create( - , + it.each([0, -1, -2, Number.NaN])( + 'throws when invalid variant quantity %p', + quantity => { + expect(() => { + render( + , + ); + }).toThrow( + 'AcceleratedCheckoutButton: Either `cartId` or `variantId` and `quantity` must be provided', ); - }).toThrow( - 'AcceleratedCheckoutButton: Either `cartId` or `variantId` and `quantity` must be provided', - ); - }); + }, + ); it('uses default values for cornerRadius', () => { - const component = renderer.create( + const {getByTestId} = render( , ); - const tree = component.toJSON(); - expect(tree).toBeTruthy(); - // @ts-expect-error tree is not null based on check above - expect(tree.props.cornerRadius).toBeUndefined(); + const nativeComponent = getByTestId('accelerated-checkout-buttons'); + expect(nativeComponent).toBeTruthy(); + expect(nativeComponent.props.cornerRadius).toBeUndefined(); }); it('passes through custom quantity and cornerRadius', () => { - const component = renderer.create( + const {getByTestId} = render( { />, ); - const tree = component.toJSON(); - expect(tree).toBeTruthy(); - // @ts-expect-error tree is not null based on check above - expect(tree.props.cornerRadius).toBe(16); + const nativeComponent = getByTestId('accelerated-checkout-buttons'); + expect(nativeComponent).toBeTruthy(); + expect(nativeComponent.props.cornerRadius).toBe(16); }); it('supports custom wallet configuration', () => { @@ -230,17 +160,162 @@ describe('AcceleratedCheckoutButtons', () => { AcceleratedCheckoutWallet.shopPay, ]; - const component = renderer.create( + const {getByTestId} = render( , ); - const tree = component.toJSON(); - expect(tree).toBeTruthy(); - // @ts-expect-error tree is not null based on check above - expect(tree.props.wallets).toEqual(customWallets); + const nativeComponent = getByTestId('accelerated-checkout-buttons'); + expect(nativeComponent).toBeTruthy(); + expect(nativeComponent.props.wallets).toEqual(customWallets); + }); + + it('forwards native fail event to onFail prop', () => { + const onFail = jest.fn(); + const {getByTestId} = render( + , + ); + + const nativeComponent = getByTestId('accelerated-checkout-buttons'); + const error = {message: 'boom'} as any; + nativeComponent.props.onFail({nativeEvent: error}); + expect(onFail).toHaveBeenCalledWith(error); + }); + + it('forwards native complete event to onComplete prop', () => { + const onComplete = jest.fn(); + const {getByTestId} = render( + , + ); + + const nativeComponent = getByTestId('accelerated-checkout-buttons'); + const details = {orderDetails: {id: '1'}} as any; + nativeComponent.props.onComplete({nativeEvent: details}); + expect(onComplete).toHaveBeenCalledWith(details); + }); + + it('calls onCancel when native cancel is invoked', () => { + const onCancel = jest.fn(); + const {getByTestId} = render( + , + ); + const nativeComponent = getByTestId('accelerated-checkout-buttons'); + nativeComponent.props.onCancel(); + expect(onCancel).toHaveBeenCalled(); + }); + + it('maps render state change to typed states including error reason', () => { + const onRenderStateChange = jest.fn(); + const {getByTestId} = render( + , + ); + const nativeComponent = getByTestId('accelerated-checkout-buttons'); + nativeComponent.props.onRenderStateChange({ + nativeEvent: {state: 'error', reason: 'bad'}, + }); + expect(onRenderStateChange).toHaveBeenCalledWith({ + state: RenderState.Error, + reason: 'bad', + }); + + nativeComponent.props.onRenderStateChange({ + nativeEvent: {state: 'rendered'}, + }); + expect(onRenderStateChange).toHaveBeenCalledWith({ + state: RenderState.Rendered, + }); + + nativeComponent.props.onRenderStateChange({ + nativeEvent: {state: 'loading'}, + }); + expect(onRenderStateChange).toHaveBeenCalledWith({ + state: RenderState.Loading, + }); + + nativeComponent.props.onRenderStateChange({ + nativeEvent: {state: 'unexpected'}, + }); + expect(onRenderStateChange).toHaveBeenCalledWith({ + state: RenderState.Unknown, + }); + }); + + it('forwards web pixel native events', () => { + const onWebPixelEvent = jest.fn(); + const {getByTestId} = render( + , + ); + const nativeComponent = getByTestId('accelerated-checkout-buttons'); + const pixel = {type: 'STANDARD'} as any; + nativeComponent.props.onWebPixelEvent({nativeEvent: pixel}); + expect(onWebPixelEvent).toHaveBeenCalledWith(pixel); + }); + + it('handles onClickLink when URL is present and ignores when absent', () => { + const onClickLink = jest.fn(); + const {getByTestId} = render( + , + ); + const nativeComponent = getByTestId('accelerated-checkout-buttons'); + nativeComponent.props.onClickLink({ + nativeEvent: {url: 'https://checkout.shopify.com'}, + }); + expect(onClickLink).toHaveBeenCalledWith('https://checkout.shopify.com'); + + onClickLink.mockClear(); + nativeComponent.props.onClickLink({nativeEvent: {}}); + expect(onClickLink).not.toHaveBeenCalled(); + }); + + it('applies dynamic height when onSizeChange is emitted', async () => { + const {getByTestId} = render( + , + ); + let nativeComponent = getByTestId('accelerated-checkout-buttons'); + await act(async () => { + nativeComponent.props.onSizeChange({nativeEvent: {height: 42}}); + }); + nativeComponent = getByTestId('accelerated-checkout-buttons'); + expect(nativeComponent.props.style).toEqual({height: 42}); + }); + + it('warns and returns null when missing identifiers in production', () => { + const originalDev = (global as any).__DEV__; + (global as any).__DEV__ = false; + (Platform as any).Version = '16.0'; + const warn = jest.spyOn(global.console, 'warn').mockImplementation(); + + const {queryByTestId} = render( + // invalid: no cartId and invalid variant path + , + ); + expect(queryByTestId('accelerated-checkout-buttons')).toBeNull(); + expect(warn).toHaveBeenCalledWith( + 'AcceleratedCheckoutButton: Either `cartId` or `variantId` and `quantity` must be provided', + ); + + warn.mockRestore(); + (global as any).__DEV__ = originalDev; }); it('handles callbacks without throwing', () => { @@ -254,7 +329,7 @@ describe('AcceleratedCheckoutButtons', () => { }; expect(() => { - renderer.create( + render( { }); it('returns null on Android', () => { - const component = renderer.create( + const {queryByTestId} = render( , ); - expect(component.toJSON()).toBeNull(); + expect(queryByTestId('accelerated-checkout-buttons')).toBeNull(); }); it('does not warn on Android even without required props', () => { - renderer.create(); + render(); expect(mockLog).not.toHaveBeenCalled(); }); diff --git a/modules/@shopify/checkout-sheet-kit/tests/context.test.tsx b/modules/@shopify/checkout-sheet-kit/tests/context.test.tsx index 8d262178..94fece11 100644 --- a/modules/@shopify/checkout-sheet-kit/tests/context.test.tsx +++ b/modules/@shopify/checkout-sheet-kit/tests/context.test.tsx @@ -1,11 +1,11 @@ import React from 'react'; -import {act, create} from 'react-test-renderer'; -import {NativeModules} from 'react-native'; +import {render, act} from '@testing-library/react-native'; +import {NativeModules, Platform} from 'react-native'; import { ShopifyCheckoutSheetProvider, useShopifyCheckoutSheet, } from '../src/context'; -import {ColorScheme, type Configuration} from '../src'; +import {ApplePayContactField, ColorScheme, type Configuration} from '../src'; const checkoutUrl = 'https://shopify.com/checkout'; const config: Configuration = { @@ -14,7 +14,6 @@ const config: Configuration = { jest.mock('react-native'); -// Helper component to test the hook const HookTestComponent = ({ onHookValue, }: { @@ -25,6 +24,8 @@ const HookTestComponent = ({ return null; }; +const MockChild = () => null; + describe('ShopifyCheckoutSheetProvider', () => { const TestComponent = ({children}: {children: React.ReactNode}) => ( @@ -36,42 +37,99 @@ describe('ShopifyCheckoutSheetProvider', () => { jest.clearAllMocks(); }); - it('renders children correctly', () => { - const component = create( + it('renders without crashing', () => { + const component = render( -
Test Child
+
, ); - expect(component.toJSON()).toBeTruthy(); + expect(component).toBeTruthy(); }); it('creates ShopifyCheckoutSheet instance with configuration', () => { - create(test); + render( + + + , + ); expect( NativeModules.ShopifyCheckoutSheetKit.setConfig, ).toHaveBeenCalledWith(config); }); - it('creates ShopifyCheckoutSheet instance with features', () => { - const features = {handleGeolocationRequests: false}; - - create( - - test + it('skips configuration when no configuration is provided', () => { + render( + + , ); expect( NativeModules.ShopifyCheckoutSheetKit.setConfig, - ).toHaveBeenCalledWith(config); + ).not.toHaveBeenCalled(); + expect( + NativeModules.ShopifyCheckoutSheetKit.configureAcceleratedCheckouts, + ).not.toHaveBeenCalled(); + }); + + it('configures accelerated checkouts when provided', async () => { + (Platform as any).Version = '17.0'; + ( + NativeModules.ShopifyCheckoutSheetKit + .configureAcceleratedCheckouts as unknown as {mockResolvedValue: any} + ).mockResolvedValue(true); + + const configWithAccelerated: Configuration = { + ...config, + acceleratedCheckouts: { + storefrontDomain: 'test-shop.myshopify.com', + storefrontAccessToken: 'shpat_test_token', + customer: {email: 'test@example.com', phoneNumber: '+123'}, + wallets: { + applePay: { + merchantIdentifier: 'merchant.test', + contactFields: [ApplePayContactField.email], + }, + }, + }, + }; + + render( + + + , + ); + + await act(async () => { + await Promise.resolve(); + }); + + expect( + NativeModules.ShopifyCheckoutSheetKit.configureAcceleratedCheckouts, + ).toHaveBeenCalledWith( + 'test-shop.myshopify.com', + 'shpat_test_token', + 'test@example.com', + '+123', + 'merchant.test', + ['email'], + ); }); it('reuses the same instance across re-renders', () => { - const component = create(test); + const {rerender} = render( + + + , + ); - component.update(updated); + rerender( + + + , + ); expect( NativeModules.ShopifyCheckoutSheetKit.setConfig.mock.calls, @@ -96,7 +154,7 @@ describe('useShopifyCheckoutSheet', () => { hookValue = value; }; - create( + render( , @@ -112,7 +170,7 @@ describe('useShopifyCheckoutSheet', () => { hookValue = value; }; - create( + render( , @@ -131,7 +189,7 @@ describe('useShopifyCheckoutSheet', () => { hookValue = value; }; - create( + render( , @@ -152,7 +210,7 @@ describe('useShopifyCheckoutSheet', () => { hookValue = value; }; - create( + render( , @@ -173,7 +231,7 @@ describe('useShopifyCheckoutSheet', () => { hookValue = value; }; - create( + render( , @@ -194,7 +252,7 @@ describe('useShopifyCheckoutSheet', () => { hookValue = value; }; - create( + render( , @@ -215,7 +273,7 @@ describe('useShopifyCheckoutSheet', () => { hookValue = value; }; - create( + render( , @@ -236,7 +294,7 @@ describe('useShopifyCheckoutSheet', () => { hookValue = value; }; - create( + render( , @@ -257,7 +315,7 @@ describe('useShopifyCheckoutSheet', () => { const newConfig = {colorScheme: ColorScheme.light}; - create( + render( , @@ -278,7 +336,7 @@ describe('useShopifyCheckoutSheet', () => { hookValue = value; }; - create( + render( , @@ -298,7 +356,7 @@ describe('useShopifyCheckoutSheet', () => { hookValue = value; }; - create( + render( , @@ -313,7 +371,7 @@ describe('useShopifyCheckoutSheet', () => { hookValue = value; }; - create( + render( , @@ -332,7 +390,7 @@ describe('ShopifyCheckoutSheetContext without provider', () => { hookValue = value; }; - create(); + render(); const config = await hookValue.getConfig(); expect(config).toBeUndefined(); diff --git a/package.json b/package.json index 2d487293..fb2a1dbc 100644 --- a/package.json +++ b/package.json @@ -32,11 +32,12 @@ "@react-native/eslint-config": "0.75.5", "@react-native/metro-config": "0.75.5", "@react-native/typescript-config": "0.75.5", + "@testing-library/react-native": "^13.3.1", "@tsconfig/react-native": "^3.0.6", "@types/jest": "^30.0.0", "@types/react": "^18", "@types/react-native-dotenv": "^0.2.1", - "@types/react-test-renderer": "^18.0.0", + "@types/react-test-renderer": "^18", "babel-jest": "^29.7.0", "eslint": "^8.19.0", "eslint-plugin-prettier": "^5.5.1", diff --git a/yarn.lock b/yarn.lock index e6070ece..679dc3a5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3912,6 +3912,26 @@ __metadata: languageName: node linkType: hard +"@testing-library/react-native@npm:^13.3.1": + version: 13.3.1 + resolution: "@testing-library/react-native@npm:13.3.1" + dependencies: + jest-matcher-utils: "npm:^30.0.5" + picocolors: "npm:^1.1.1" + pretty-format: "npm:^30.0.5" + redent: "npm:^3.0.0" + peerDependencies: + jest: ">=29.0.0" + react: ">=18.2.0" + react-native: ">=0.71" + react-test-renderer: ">=18.2.0" + peerDependenciesMeta: + jest: + optional: true + checksum: 10c0/85979432a5509d9ed855e570facfd1cb4f4854a48da90dcacea9243e1f8b5cd27f8fc365d1f34f7f059e5a7a89fcf62720e73c050103bb566161d8848c508a58 + languageName: node + linkType: hard + "@tsconfig/react-native@npm:^3.0.6": version: 3.0.6 resolution: "@tsconfig/react-native@npm:3.0.6" @@ -4085,12 +4105,12 @@ __metadata: languageName: node linkType: hard -"@types/react-test-renderer@npm:^18.0.0": - version: 18.0.6 - resolution: "@types/react-test-renderer@npm:18.0.6" +"@types/react-test-renderer@npm:^18": + version: 18.3.1 + resolution: "@types/react-test-renderer@npm:18.3.1" dependencies: - "@types/react": "npm:*" - checksum: 10c0/f490d4379e8d095f8fa91700ceb37d0fe5a96d7cc0c51e9d7127fc3d2dc4e37a382dd6215b295b300037985cb8938cb5088130102ad14b79e4622e7e520c5a3b + "@types/react": "npm:^18" + checksum: 10c0/9fc8467ff1a3f14be6cc3498a75fc788d2c92c0fffa7bf21269ed5d9d82db9195bf2178ddc42ea16a0836995c1b77601c6be8abb27bd1864668c418c6d0e5a3b languageName: node linkType: hard @@ -5336,11 +5356,12 @@ __metadata: "@react-native/eslint-config": "npm:0.75.5" "@react-native/metro-config": "npm:0.75.5" "@react-native/typescript-config": "npm:0.75.5" + "@testing-library/react-native": "npm:^13.3.1" "@tsconfig/react-native": "npm:^3.0.6" "@types/jest": "npm:^30.0.0" "@types/react": "npm:^18" "@types/react-native-dotenv": "npm:^0.2.1" - "@types/react-test-renderer": "npm:^18.0.0" + "@types/react-test-renderer": "npm:^18" babel-jest: "npm:^29.7.0" eslint: "npm:^8.19.0" eslint-plugin-prettier: "npm:^5.5.1" @@ -8158,7 +8179,7 @@ __metadata: languageName: node linkType: hard -"jest-matcher-utils@npm:30.0.5": +"jest-matcher-utils@npm:30.0.5, jest-matcher-utils@npm:^30.0.5": version: 30.0.5 resolution: "jest-matcher-utils@npm:30.0.5" dependencies: @@ -9279,6 +9300,13 @@ __metadata: languageName: node linkType: hard +"min-indent@npm:^1.0.0": + version: 1.0.1 + resolution: "min-indent@npm:1.0.1" + checksum: 10c0/7e207bd5c20401b292de291f02913230cb1163abca162044f7db1d951fa245b174dc00869d40dd9a9f32a885ad6a5f3e767ee104cf278f399cb4e92d3f582d5c + languageName: node + linkType: hard + "minimatch@npm:^3.0.2, minimatch@npm:^3.0.4, minimatch@npm:^3.0.5, minimatch@npm:^3.1.1, minimatch@npm:^3.1.2": version: 3.1.2 resolution: "minimatch@npm:3.1.2" @@ -10063,7 +10091,7 @@ __metadata: languageName: node linkType: hard -"pretty-format@npm:30.0.5": +"pretty-format@npm:30.0.5, pretty-format@npm:^30.0.5": version: 30.0.5 resolution: "pretty-format@npm:30.0.5" dependencies: @@ -10545,6 +10573,16 @@ __metadata: languageName: node linkType: hard +"redent@npm:^3.0.0": + version: 3.0.0 + resolution: "redent@npm:3.0.0" + dependencies: + indent-string: "npm:^4.0.0" + strip-indent: "npm:^3.0.0" + checksum: 10c0/d64a6b5c0b50eb3ddce3ab770f866658a2b9998c678f797919ceb1b586bab9259b311407280bd80b804e2a7c7539b19238ae6a2a20c843f1a7fcff21d48c2eae + languageName: node + linkType: hard + "reflect.getprototypeof@npm:^1.0.4": version: 1.0.4 resolution: "reflect.getprototypeof@npm:1.0.4" @@ -11444,6 +11482,15 @@ __metadata: languageName: node linkType: hard +"strip-indent@npm:^3.0.0": + version: 3.0.0 + resolution: "strip-indent@npm:3.0.0" + dependencies: + min-indent: "npm:^1.0.0" + checksum: 10c0/ae0deaf41c8d1001c5d4fbe16cb553865c1863da4fae036683b474fa926af9fc121e155cb3fc57a68262b2ae7d5b8420aa752c97a6428c315d00efe2a3875679 + languageName: node + linkType: hard + "strip-json-comments@npm:^3.1.1": version: 3.1.1 resolution: "strip-json-comments@npm:3.1.1"