diff --git a/packages/rtn-web-browser/__tests__/apis/openAuthSessionAsync.test.ts b/packages/rtn-web-browser/__tests__/apis/openAuthSessionAsync.test.ts new file mode 100644 index 00000000000..01fa7127e9e --- /dev/null +++ b/packages/rtn-web-browser/__tests__/apis/openAuthSessionAsync.test.ts @@ -0,0 +1,401 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { AppState, Linking, Platform } from 'react-native'; + +import { + isChromebook, + openAuthSessionAsync, +} from '../../src/apis/openAuthSessionAsync'; +import { nativeModule } from '../../src/nativeModule'; +import { + mockDeepLinkUrl, + mockRedirectUrls, + mockReturnUrl, + mockUrl, +} from '../testUtils/data'; + +// Mock React Native modules +jest.mock('react-native', () => ({ + Platform: { OS: 'ios' }, + AppState: { + currentState: 'active', + addEventListener: jest.fn(), + }, + Linking: { + addEventListener: jest.fn(), + openURL: jest.fn(), + }, + NativeModules: {}, +})); + +// Mock EmitterSubscription type +const mockEmitterSubscription = { + remove: jest.fn(), + emitter: {}, + listener: jest.fn(), + context: {}, + eventType: 'test', + key: 'test-key', + subscriber: {}, +} as any; + +// Mock native module +jest.mock('../../src/nativeModule', () => ({ + nativeModule: { + openAuthSessionAsync: jest.fn(), + }, +})); + +describe('openAuthSessionAsync', () => { + const mockNativeModule = nativeModule.openAuthSessionAsync as jest.Mock; + const mockPlatform = Platform as jest.Mocked; + const mockAppState = AppState as jest.Mocked; + const mockLinking = Linking as jest.Mocked; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('iOS platform', () => { + beforeEach(() => { + mockPlatform.OS = 'ios'; + }); + + it('calls iOS native module with correct parameters', async () => { + mockNativeModule.mockResolvedValue(mockReturnUrl); + + const result = await openAuthSessionAsync(mockUrl, mockRedirectUrls); + + expect(mockNativeModule).toHaveBeenCalledWith( + mockUrl, + mockDeepLinkUrl, + false, + ); + expect(result).toBe(mockReturnUrl); + }); + + it('enforces HTTPS URLs', async () => { + const httpUrl = 'http://example.com/auth'; + mockNativeModule.mockResolvedValue(mockReturnUrl); + + await openAuthSessionAsync(httpUrl, mockRedirectUrls); + + expect(mockNativeModule).toHaveBeenCalledWith( + mockUrl, + mockDeepLinkUrl, + false, + ); + }); + + it('passes prefersEphemeralSession parameter', async () => { + mockNativeModule.mockResolvedValue(mockReturnUrl); + + await openAuthSessionAsync(mockUrl, mockRedirectUrls, true); + + expect(mockNativeModule).toHaveBeenCalledWith( + mockUrl, + mockDeepLinkUrl, + true, + ); + }); + + it('finds first non-web redirect URL', async () => { + const redirectUrls = [ + 'https://web.com', + 'myapp://callback', + 'anotherapp://test', + ]; + mockNativeModule.mockResolvedValue(mockReturnUrl); + + await openAuthSessionAsync(mockUrl, redirectUrls); + + expect(mockNativeModule).toHaveBeenCalledWith( + mockUrl, + 'myapp://callback', + false, + ); + }); + + it('handles undefined redirect URL when no deep links found', async () => { + const webOnlyUrls = ['https://web.com', 'http://another.com']; + mockNativeModule.mockResolvedValue(mockReturnUrl); + + await openAuthSessionAsync(mockUrl, webOnlyUrls); + + expect(mockNativeModule).toHaveBeenCalledWith(mockUrl, undefined, false); + }); + }); + + describe('Android platform', () => { + beforeEach(() => { + mockPlatform.OS = 'android'; + mockAppState.currentState = 'active'; + }); + + it('sets up listeners and calls native module', async () => { + const mockAppStateListener = { ...mockEmitterSubscription }; + const mockLinkingListener = { ...mockEmitterSubscription }; + + mockAppState.addEventListener.mockReturnValue(mockAppStateListener); + mockLinking.addEventListener.mockReturnValue(mockLinkingListener); + mockNativeModule.mockResolvedValue(undefined); + + // Simulate app state change from background to active + mockAppState.currentState = 'background'; + mockAppState.addEventListener.mockImplementation((event, callback) => { + // Immediately trigger the callback to resolve the promise + setTimeout(() => { + callback('active'); + }, 0); + + return mockAppStateListener; + }); + + const result = await openAuthSessionAsync(mockUrl, mockRedirectUrls); + + expect(mockNativeModule).toHaveBeenCalledWith(mockUrl); + expect(mockAppState.addEventListener).toHaveBeenCalledWith( + 'change', + expect.any(Function), + ); + expect(mockLinking.addEventListener).toHaveBeenCalledWith( + 'url', + expect.any(Function), + ); + expect(result).toBeNull(); + }); + + it('resolves with redirect URL when matching URL received', async () => { + const mockAppStateListener = { ...mockEmitterSubscription }; + const mockLinkingListener = { ...mockEmitterSubscription }; + + mockAppState.addEventListener.mockReturnValue(mockAppStateListener); + mockLinking.addEventListener.mockImplementation((event, callback) => { + // Immediately trigger the callback to resolve the promise + setTimeout(() => { + callback({ url: mockReturnUrl }); + }, 0); + + return mockLinkingListener; + }); + mockNativeModule.mockResolvedValue(undefined); + + const result = await openAuthSessionAsync(mockUrl, mockRedirectUrls); + + expect(result).toBe(mockReturnUrl); + }); + + it('ignores non-matching redirect URLs', async () => { + const mockAppStateListener = { ...mockEmitterSubscription }; + const mockLinkingListener = { ...mockEmitterSubscription }; + + mockAppState.currentState = 'background'; + let appStateCallback: any; + + mockAppState.addEventListener.mockImplementation((event, callback) => { + appStateCallback = callback; + + return mockAppStateListener; + }); + + mockLinking.addEventListener.mockImplementation((event, callback) => { + // First call with non-matching URL (should be ignored) + setTimeout(() => { + callback({ url: 'other://app' }); + }, 0); + // Then trigger app state change to resolve + setTimeout(() => { + appStateCallback('active'); + }, 10); + + return mockLinkingListener; + }); + + mockNativeModule.mockResolvedValue(undefined); + + const result = await openAuthSessionAsync(mockUrl, mockRedirectUrls); + + expect(result).toBeNull(); + }); + + it('cleans up listeners after completion', async () => { + const mockAppStateListener = { ...mockEmitterSubscription }; + const mockLinkingListener = { ...mockEmitterSubscription }; + + mockAppState.currentState = 'background'; + mockAppState.addEventListener.mockReturnValue(mockAppStateListener); + mockLinking.addEventListener.mockReturnValue(mockLinkingListener); + mockNativeModule.mockResolvedValue(undefined); + + mockAppState.addEventListener.mockImplementation((event, callback) => { + setTimeout(() => { + callback('active'); + }, 0); + + return mockAppStateListener; + }); + + await openAuthSessionAsync(mockUrl, mockRedirectUrls); + + expect(mockAppStateListener.remove).toHaveBeenCalled(); + expect(mockLinkingListener.remove).toHaveBeenCalled(); + }); + + it('handles app state transition from background to active', async () => { + const mockAppStateListener = { ...mockEmitterSubscription }; + const mockLinkingListener = { ...mockEmitterSubscription }; + + // Set initial state to background to test the transition + mockAppState.currentState = 'background'; + mockAppState.addEventListener.mockReturnValue(mockAppStateListener); + mockLinking.addEventListener.mockReturnValue(mockLinkingListener); + mockNativeModule.mockResolvedValue(undefined); + + mockAppState.addEventListener.mockImplementation((event, callback) => { + // Simulate transition from background to active + setTimeout(() => { + callback('active'); + }, 0); + + return mockAppStateListener; + }); + + const result = await openAuthSessionAsync(mockUrl, mockRedirectUrls); + + expect(result).toBeNull(); + }); + + it('handles app state change when already active', async () => { + const mockAppStateListener = { ...mockEmitterSubscription }; + const mockLinkingListener = { ...mockEmitterSubscription }; + + // Set initial state to active + mockAppState.currentState = 'active'; + mockAppState.addEventListener.mockReturnValue(mockAppStateListener); + mockLinking.addEventListener.mockReturnValue(mockLinkingListener); + mockNativeModule.mockResolvedValue(undefined); + + mockAppState.addEventListener.mockImplementation((event, callback) => { + // Simulate state change from active to background then back to active + setTimeout(() => { + callback('background'); // This should not trigger resolve + setTimeout(() => { + callback('active'); + }, 0); // This should trigger resolve + }, 0); + + return mockAppStateListener; + }); + + const result = await openAuthSessionAsync(mockUrl, mockRedirectUrls); + + expect(result).toBeNull(); + }); + }); + + describe('unsupported platform', () => { + it('returns undefined for unsupported platforms', async () => { + mockPlatform.OS = 'web' as any; + + const result = await openAuthSessionAsync(mockUrl, mockRedirectUrls); + + expect(result).toBeUndefined(); + expect(mockNativeModule).not.toHaveBeenCalled(); + }); + }); + + describe('isChromebook', () => { + it('returns false by default', async () => { + const result = await isChromebook(); + expect(result).toBe(false); + }); + + it('returns true when NativeModules.ChromeOS.isChromeOS returns true', async () => { + const mockReactNative = require('react-native'); + mockReactNative.NativeModules.ChromeOS = { + isChromeOS: jest.fn().mockResolvedValue(true), + }; + + const result = await isChromebook(); + expect(result).toBe(true); + + // Clean up + delete mockReactNative.NativeModules.ChromeOS; + }); + }); + + describe('ChromeOS flow', () => { + beforeEach(() => { + mockPlatform.OS = 'android'; + mockAppState.currentState = 'active'; + }); + + it('uses ChromeOS flow when isChromebook returns true', async () => { + const mockAppStateListener = { ...mockEmitterSubscription }; + const mockLinkingListener = { ...mockEmitterSubscription }; + + // Mock isChromebook to return true + const mockReactNative = require('react-native'); + mockReactNative.NativeModules.ChromeOS = { + isChromeOS: jest.fn().mockResolvedValue(true), + }; + + mockAppState.addEventListener.mockReturnValue(mockAppStateListener); + mockLinking.addEventListener.mockReturnValue(mockLinkingListener); + mockLinking.openURL.mockResolvedValue(undefined); + + // Mock redirect URL received + mockLinking.addEventListener.mockImplementation( + (_event: any, callback: any) => { + setTimeout(() => { + callback({ url: mockReturnUrl }); + }, 0); + + return mockLinkingListener; + }, + ); + + const result = await openAuthSessionAsync(mockUrl, mockRedirectUrls); + + expect(mockLinking.openURL).toHaveBeenCalledWith(mockUrl); + expect(mockLinking.openURL).toHaveBeenCalledWith(mockReturnUrl); + expect(result).toBe(mockReturnUrl); + + // Clean up + delete mockReactNative.NativeModules.ChromeOS; + }); + + it('handles isChromebook error and falls back to Android', async () => { + const mockAppStateListener = { ...mockEmitterSubscription }; + const mockLinkingListener = { ...mockEmitterSubscription }; + + // Mock isChromebook to throw error + const mockReactNative = require('react-native'); + mockReactNative.NativeModules.ChromeOS = { + isChromeOS: jest.fn().mockRejectedValue(new Error('Detection failed')), + }; + + mockAppState.currentState = 'background'; + mockAppState.addEventListener.mockReturnValue(mockAppStateListener); + mockLinking.addEventListener.mockReturnValue(mockLinkingListener); + mockNativeModule.mockResolvedValue(undefined); + + mockAppState.addEventListener.mockImplementation((event, callback) => { + setTimeout(() => { + callback('active'); + }, 0); + + return mockAppStateListener; + }); + + const result = await openAuthSessionAsync(mockUrl, mockRedirectUrls); + + expect(mockNativeModule).toHaveBeenCalledWith(mockUrl); + expect(result).toBeNull(); + + // Clean up + delete mockReactNative.NativeModules.ChromeOS; + }); + }); +}); diff --git a/packages/rtn-web-browser/__tests__/constants.test.ts b/packages/rtn-web-browser/__tests__/constants.test.ts new file mode 100644 index 00000000000..3110c79008a --- /dev/null +++ b/packages/rtn-web-browser/__tests__/constants.test.ts @@ -0,0 +1,55 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { PACKAGE_NAME } from '../src/constants'; + +jest.mock('react-native', () => ({ + Platform: { + select: jest.fn(), + }, +})); + +// const mockPlatformSelect = require('react-native').Platform.select; + +describe('constants', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('exports correct package name', () => { + expect(PACKAGE_NAME).toBe('@aws-amplify/rtn-web-browser'); + }); + + it('generates iOS-specific linking error', () => { + // Re-mock after resetModules + jest.resetModules(); + jest.doMock('react-native', () => ({ + Platform: { + select: jest.fn().mockReturnValue("- You have run 'pod install'\n"), + }, + })); + + const { LINKING_ERROR: freshLinkingError } = require('../src/constants'); + + expect(freshLinkingError).toContain('pod install'); + expect(freshLinkingError).toContain('@aws-amplify/rtn-web-browser'); + expect(freshLinkingError).toContain('rebuilt the app'); + expect(freshLinkingError).toContain('not using Expo Go'); + }); + + it('generates generic linking error for other platforms', () => { + // Re-mock after resetModules + jest.resetModules(); + jest.doMock('react-native', () => ({ + Platform: { + select: jest.fn().mockReturnValue(''), + }, + })); + + const { LINKING_ERROR: freshLinkingError } = require('../src/constants'); + + expect(freshLinkingError).toContain('rebuilt the app'); + expect(freshLinkingError).toContain('@aws-amplify/rtn-web-browser'); + expect(freshLinkingError).not.toContain('pod install'); + }); +}); diff --git a/packages/rtn-web-browser/__tests__/nativeModule.test.ts b/packages/rtn-web-browser/__tests__/nativeModule.test.ts new file mode 100644 index 00000000000..a88bb695ab6 --- /dev/null +++ b/packages/rtn-web-browser/__tests__/nativeModule.test.ts @@ -0,0 +1,63 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { LINKING_ERROR } from '../src/constants'; + +// Mock React Native before importing nativeModule +jest.mock('react-native', () => ({ + NativeModules: {}, + Platform: { + select: jest.fn().mockReturnValue(''), + }, +})); + +describe('nativeModule', () => { + beforeEach(() => { + jest.resetModules(); + }); + + it('returns native module when available', () => { + const mockModule = { openAuthSessionAsync: jest.fn() }; + + jest.doMock('react-native', () => ({ + NativeModules: { + AmplifyRTNWebBrowser: mockModule, + }, + Platform: { + select: jest.fn().mockReturnValue(''), + }, + })); + + const { nativeModule } = require('../src/nativeModule'); + expect(nativeModule).toBe(mockModule); + // Test that the module actually works + nativeModule.openAuthSessionAsync('test'); + expect(mockModule.openAuthSessionAsync).toHaveBeenCalledWith('test'); + }); + + it('throws error when native module not available', () => { + jest.doMock('react-native', () => ({ + NativeModules: {}, + Platform: { + select: jest.fn().mockReturnValue(''), + }, + })); + + const { nativeModule } = require('../src/nativeModule'); + expect(() => nativeModule.openAuthSessionAsync('test')).toThrow( + LINKING_ERROR, + ); + }); + + it('throws error for any method call when module unavailable', () => { + jest.doMock('react-native', () => ({ + NativeModules: {}, + Platform: { + select: jest.fn().mockReturnValue(''), + }, + })); + + const { nativeModule } = require('../src/nativeModule'); + expect(() => nativeModule.someOtherMethod()).toThrow(LINKING_ERROR); + }); +}); diff --git a/packages/rtn-web-browser/__tests__/testUtils/data.ts b/packages/rtn-web-browser/__tests__/testUtils/data.ts new file mode 100644 index 00000000000..ffe6e6d511c --- /dev/null +++ b/packages/rtn-web-browser/__tests__/testUtils/data.ts @@ -0,0 +1,12 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +export const mockUrl = 'https://example.com/auth'; +export const mockRedirectUrls = [ + 'myapp://callback', + 'https://web.callback.com', +]; +export const mockDeepLinkUrl = 'myapp://callback'; +export const mockWebUrl = 'https://web.callback.com'; +export const mockReturnUrl = 'myapp://callback?code=123'; +export const completionHandlerId = 'completion-handler-id'; diff --git a/packages/rtn-web-browser/jest.config.js b/packages/rtn-web-browser/jest.config.js index fd22133a060..62a5758caa9 100644 --- a/packages/rtn-web-browser/jest.config.js +++ b/packages/rtn-web-browser/jest.config.js @@ -1,5 +1,24 @@ module.exports = { ...require('../../jest.config'), + collectCoverageFrom: [ + 'src/**/*.{ts,tsx}', + '!src/**/*.d.ts', + '!src/**/*.test.{ts,tsx}', + '!src/**/*.spec.{ts,tsx}', + '!src/**/index.ts', // Re-export files + '!src/types/**/*', // Type definition files + ], + coveragePathIgnorePatterns: [ + '/node_modules/', + '/dist/', + '/coverage/', + '/__tests__/', + '/android/', + '/ios/', + '\\.d\\.ts$', + 'index\\.ts$', + '/types/', + ], coverageThreshold: { global: { branches: 25, diff --git a/packages/rtn-web-browser/package.json b/packages/rtn-web-browser/package.json index ee5e6408bde..22c8b20eb05 100644 --- a/packages/rtn-web-browser/package.json +++ b/packages/rtn-web-browser/package.json @@ -11,7 +11,7 @@ "access": "public" }, "scripts": { - "prepare:ios": "echo 'no-op'", + "prepare:ios": "echo 'no-op'", "prepare:android": "echo 'no-op'", "test": "jest -w 1 --coverage --logHeapUsage", "test:ios": "echo 'no-op'", diff --git a/packages/rtn-web-browser/src/apis/__tests__/openAuthSessionAsync.test.ts b/packages/rtn-web-browser/src/apis/__tests__/openAuthSessionAsync.test.ts deleted file mode 100644 index afbb034ff6e..00000000000 --- a/packages/rtn-web-browser/src/apis/__tests__/openAuthSessionAsync.test.ts +++ /dev/null @@ -1,78 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { Platform } from 'react-native'; - -import { nativeModule } from '../../nativeModule'; -import { isChromebook, openAuthSessionAsync } from '../openAuthSessionAsync'; - -jest.mock('react-native', () => ({ - Platform: { OS: 'ios' }, - AppState: { addEventListener: jest.fn() }, - Linking: { addEventListener: jest.fn() }, - NativeModules: {}, -})); - -jest.mock('../../nativeModule', () => ({ - nativeModule: { openAuthSessionAsync: jest.fn() }, -})); - -const mockPlatform = Platform as jest.Mocked; -const mockNativeModule = nativeModule as jest.Mocked; - -describe('openAuthSessionAsync', () => { - beforeEach(() => { - jest.clearAllMocks(); - mockPlatform.OS = 'ios'; - }); - - describe('isChromebook', () => { - it('returns false by default', async () => { - const result = await isChromebook(); - expect(result).toBe(false); - }); - }); - - describe('openAuthSessionAsync', () => { - it('enforces HTTPS on URLs', async () => { - mockNativeModule.openAuthSessionAsync.mockResolvedValue('result'); - - await openAuthSessionAsync('http://example.com', ['myapp://callback']); - - expect(mockNativeModule.openAuthSessionAsync).toHaveBeenCalledWith( - 'https://example.com', - 'myapp://callback', - false, - ); - }); - - it('calls iOS implementation', async () => { - mockNativeModule.openAuthSessionAsync.mockResolvedValue('result'); - - const result = await openAuthSessionAsync( - 'https://example.com', - ['myapp://callback'], - true, - ); - - expect(result).toBe('result'); - expect(mockNativeModule.openAuthSessionAsync).toHaveBeenCalledWith( - 'https://example.com', - 'myapp://callback', - true, - ); - }); - - it('finds first non-web URL for redirect', async () => { - const redirectUrls = ['https://web.com', 'myapp://deep']; - - await openAuthSessionAsync('https://example.com', redirectUrls); - - expect(mockNativeModule.openAuthSessionAsync).toHaveBeenCalledWith( - 'https://example.com', - 'myapp://deep', - false, - ); - }); - }); -});