diff --git a/ui/pages/onboarding-flow/onboarding-flow.test.tsx b/ui/pages/onboarding-flow/onboarding-flow.test.tsx index c679af9a7cac..d4e6544aa00b 100644 --- a/ui/pages/onboarding-flow/onboarding-flow.test.tsx +++ b/ui/pages/onboarding-flow/onboarding-flow.test.tsx @@ -23,10 +23,16 @@ import { import { CHAIN_IDS } from '../../../shared/constants/network'; import { createNewVaultAndGetSeedPhrase, + restoreSocialBackupAndGetSeedPhrase, + setCompletedOnboarding, + setCompletedOnboardingWithSidepanel, + setUseSidePanelAsDefault, unlockAndGetSeedPhrase, } from '../../store/actions'; import { mockNetworkState } from '../../../test/stub/networks'; import { FirstTimeFlowType } from '../../../shared/constants/onboarding'; +import { getIsSeedlessOnboardingFeatureEnabled } from '../../../shared/lib/environment'; +import { useSidePanelEnabled } from '../../hooks/useSidePanelEnabled'; import OnboardingFlow from './onboarding-flow'; // Mock mmLazy to return a synchronous component instead of React.lazy. @@ -49,6 +55,31 @@ jest.mock('react-router-dom', () => ({ useNavigate: () => mockUseNavigate, })); +jest.mock('../unlock-page', () => { + const reactModule = jest.requireActual('react'); + + return function mockUnlock({ + onSubmit, + }: { + onSubmit: (password: string) => Promise; + }) { + const [password, setPassword] = reactModule.useState(''); + + return ( +
+ setPassword(event.target.value)} + /> + +
+ ); + }; +}); + // Wrapper component that provides proper route context for nested Routes // OnboardingFlow uses relative paths expecting to be mounted at /onboarding/* const OnboardingFlowWithRouteContext = () => ( @@ -92,9 +123,17 @@ jest.mock('../../hooks/identity/useBackupAndSync', () => ({ }), })); +jest.mock('../../components/app/toast-master/utils', () => ({ + submitRequestToBackgroundAndCatch: jest.fn(), +})); + jest.mock('../../store/actions', () => ({ - createNewVaultAndGetSeedPhrase: jest.fn().mockResolvedValue(null), - unlockAndGetSeedPhrase: jest.fn().mockResolvedValue(null), + createNewVaultAndGetSeedPhrase: jest.fn(() => async () => null), + restoreSocialBackupAndGetSeedPhrase: jest.fn(() => async () => null), + setCompletedOnboarding: jest.fn(() => async () => null), + setCompletedOnboardingWithSidepanel: jest.fn(() => async () => null), + setUseSidePanelAsDefault: jest.fn(() => async () => null), + unlockAndGetSeedPhrase: jest.fn(() => async () => null), createNewVaultAndRestore: jest.fn(), setOnboardingDate: jest.fn(() => ({ type: 'TEST_DISPATCH' })), hideLoadingIndication: jest.fn(() => async () => ({ @@ -103,10 +142,33 @@ jest.mock('../../store/actions', () => ({ setIsBackupAndSyncFeatureEnabled: jest.fn( () => async () => Promise.resolve(), ), - checkIsSeedlessPasswordOutdated: jest.fn(() => Promise.resolve()), - getIsSeedlessOnboardingUserAuthenticated: jest.fn(() => Promise.resolve()), + checkIsSeedlessPasswordOutdated: jest.fn(() => async () => Promise.resolve()), + getIsSeedlessOnboardingUserAuthenticated: jest.fn( + () => async () => Promise.resolve(false), + ), +})); + +jest.mock('../../../shared/lib/environment', () => ({ + ...jest.requireActual('../../../shared/lib/environment'), + getIsSeedlessOnboardingFeatureEnabled: jest.fn(() => false), +})); + +jest.mock('../../hooks/useSidePanelEnabled', () => ({ + useSidePanelEnabled: jest.fn(() => false), })); +function createDeferred() { + let resolvePromise: ( + value: ResolvedValue | PromiseLike, + ) => void = () => undefined; + + const promise = new Promise((resolvedValue) => { + resolvePromise = resolvedValue; + }); + + return { promise, resolve: resolvePromise }; +} + describe('Onboarding Flow', () => { const mockState = { metamask: { @@ -146,8 +208,42 @@ describe('Onboarding Flow', () => { const store = configureMockStore([thunk])(mockState); + const createStore = (metamaskState = {}) => + configureMockStore([thunk])({ + ...mockState, + metamask: { + ...mockState.metamask, + ...metamaskState, + }, + }); + + const renderUnlockPage = (metamaskState = {}) => + renderWithProvider( + , + createStore(metamaskState), + ONBOARDING_UNLOCK_ROUTE, + ); + + const submitUnlock = (getByTestId: (testId: string) => HTMLElement) => { + fireEvent.change(getByTestId('unlock-password-input'), { + target: { value: 'a-new-password' }, + }); + fireEvent.click(getByTestId('unlock-submit')); + }; + + beforeEach(() => { + ( + getIsSeedlessOnboardingFeatureEnabled as jest.MockedFunction< + typeof getIsSeedlessOnboardingFeatureEnabled + > + ).mockReturnValue(false); + ( + useSidePanelEnabled as jest.MockedFunction + ).mockReturnValue(false); + }); + afterEach(() => { - jest.resetAllMocks(); + jest.clearAllMocks(); }); it('should route to the default route when completedOnboarding and seedPhraseBackedUp is true', () => { @@ -295,26 +391,115 @@ describe('Onboarding Flow', () => { }); it('should call unlockAndGetSeedPhrase when unlocking with a password', async () => { - const { getByLabelText, getByText } = renderWithProvider( - , - configureMockStore([thunk])({ - ...mockState, - metamask: { - ...mockState.metamask, - firstTimeFlowType: FirstTimeFlowType.import, - }, - }), - ONBOARDING_UNLOCK_ROUTE, - ); + const { getByTestId } = renderUnlockPage({ + firstTimeFlowType: FirstTimeFlowType.import, + }); - const password = 'a-new-password'; - const inputPassword = getByLabelText(messages.password.message); - const unlockButton = getByText(messages.unlock.message); + submitUnlock(getByTestId); - fireEvent.change(inputPassword, { target: { value: password } }); - fireEvent.click(unlockButton); await waitFor(() => expect(unlockAndGetSeedPhrase).toHaveBeenCalled()); }); + + it('keeps the loading overlay visible until social import sidepanel rehydration completes', async () => { + const sidepanelCompletion = createDeferred(); + + ( + getIsSeedlessOnboardingFeatureEnabled as jest.MockedFunction< + typeof getIsSeedlessOnboardingFeatureEnabled + > + ).mockReturnValue(true); + ( + useSidePanelEnabled as jest.MockedFunction + ).mockReturnValue(true); + ( + restoreSocialBackupAndGetSeedPhrase as jest.MockedFunction< + typeof restoreSocialBackupAndGetSeedPhrase + > + ).mockImplementation(() => async () => 'seed phrase'); + ( + setUseSidePanelAsDefault as jest.MockedFunction< + typeof setUseSidePanelAsDefault + > + ).mockImplementation(() => async () => ({ useSidePanelAsDefault: true })); + ( + setCompletedOnboardingWithSidepanel as jest.MockedFunction< + typeof setCompletedOnboardingWithSidepanel + > + ).mockImplementation(() => async () => await sidepanelCompletion.promise); + + const { container, getByTestId } = renderUnlockPage({ + firstTimeFlowType: FirstTimeFlowType.socialImport, + }); + + submitUnlock(getByTestId); + + await waitFor(() => { + expect(restoreSocialBackupAndGetSeedPhrase).toHaveBeenCalled(); + expect(setUseSidePanelAsDefault).toHaveBeenCalledWith(true); + expect(setCompletedOnboardingWithSidepanel).toHaveBeenCalled(); + }); + + expect(container.querySelector('.loading-overlay')).toBeInTheDocument(); + expect(mockUseNavigate).not.toHaveBeenCalled(); + + sidepanelCompletion.resolve(); + + await waitFor(() => { + expect(mockUseNavigate).toHaveBeenCalledWith(DEFAULT_ROUTE, { + replace: true, + }); + }); + await waitFor(() => { + expect( + container.querySelector('.loading-overlay'), + ).not.toBeInTheDocument(); + }); + }); + + it('keeps the loading overlay visible until social import rehydration completes without sidepanel', async () => { + const onboardingCompletion = createDeferred(); + + ( + getIsSeedlessOnboardingFeatureEnabled as jest.MockedFunction< + typeof getIsSeedlessOnboardingFeatureEnabled + > + ).mockReturnValue(true); + ( + restoreSocialBackupAndGetSeedPhrase as jest.MockedFunction< + typeof restoreSocialBackupAndGetSeedPhrase + > + ).mockImplementation(() => async () => 'seed phrase'); + ( + setCompletedOnboarding as jest.MockedFunction< + typeof setCompletedOnboarding + > + ).mockImplementation( + () => async () => await onboardingCompletion.promise, + ); + + const { container, getByTestId } = renderUnlockPage({ + firstTimeFlowType: FirstTimeFlowType.socialImport, + }); + + submitUnlock(getByTestId); + + await waitFor(() => { + expect(restoreSocialBackupAndGetSeedPhrase).toHaveBeenCalled(); + expect(setCompletedOnboarding).toHaveBeenCalled(); + }); + + expect(container.querySelector('.loading-overlay')).toBeInTheDocument(); + expect(mockUseNavigate).not.toHaveBeenCalled(); + + onboardingCompletion.resolve(); + + await waitFor(() => { + expect( + container.querySelector('.loading-overlay'), + ).not.toBeInTheDocument(); + }); + expect(mockUseNavigate).not.toHaveBeenCalled(); + }); }); it('should render privacy settings', () => { diff --git a/ui/pages/onboarding-flow/onboarding-flow.tsx b/ui/pages/onboarding-flow/onboarding-flow.tsx index cf04adeee864..7f6cc14b538f 100644 --- a/ui/pages/onboarding-flow/onboarding-flow.tsx +++ b/ui/pages/onboarding-flow/onboarding-flow.tsx @@ -49,6 +49,8 @@ import { restoreSocialBackupAndGetSeedPhrase, createNewVaultAndSyncWithSocial, setCompletedOnboarding, + setUseSidePanelAsDefault, + setCompletedOnboardingWithSidepanel, } from '../../store/actions'; import { getFirstTimeFlowType, @@ -71,6 +73,7 @@ import { useTheme } from '../../hooks/useTheme'; import { ThemeType } from '../../../shared/constants/preferences'; import { isFlask } from '../../../shared/lib/build-types'; import { mmLazy } from '../../helpers/utils/mm-lazy'; +import { useSidePanelEnabled } from '../../hooks/useSidePanelEnabled'; import OnboardingFlowSwitch from './onboarding-flow-switch/onboarding-flow-switch'; import CreatePassword from './create-password/create-password'; import ReviewRecoveryPhrase from './recovery-phrase/review-recovery-phrase'; @@ -107,6 +110,7 @@ export default function OnboardingFlow() { const { pathname, search } = location; const navigate = useNavigate(); const theme = useTheme(); + const isSidePanelEnabled = useSidePanelEnabled(); const completedOnboarding: boolean = useSelector(getCompletedOnboarding); const openedWithSidepanel = useSelector(getOpenedWithSidepanel); const nextRoute = useSelector(getFirstTimeFlowTypeRouteAfterUnlock); @@ -232,6 +236,21 @@ export default function OnboardingFlow() { } }; + const handleSocialLoginRehydration = async () => { + if (isSidePanelEnabled) { + await dispatch(setUseSidePanelAsDefault(true)); + await dispatch(setCompletedOnboardingWithSidepanel()); + + // for sidepanel, we need to navigate to the next route (i.e. Home) + navigate(nextRoute, { replace: true }); + } else { + // For existing social login users, set onboarding complete + // The useEffect watching completedOnboarding will handle navigation to DEFAULT_ROUTE + // Don't navigate here - let the useEffect handle it to avoid duplicate navigations + await dispatch(setCompletedOnboarding()); + } + }; + const handleUnlock = async (password: string) => { try { setIsLoading(true); @@ -254,10 +273,7 @@ export default function OnboardingFlow() { setSecretRecoveryPhrase(retrievedSecretRecoveryPhrase); } if (firstTimeFlowType === FirstTimeFlowType.socialImport) { - // For existing social login users, set onboarding complete - // The useEffect watching completedOnboarding will handle navigation to DEFAULT_ROUTE - await dispatch(setCompletedOnboarding()); - // Don't navigate here - let the useEffect handle it to avoid duplicate navigations + await handleSocialLoginRehydration(); return; } navigate(nextRoute, { replace: true });