Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
227 changes: 206 additions & 21 deletions ui/pages/onboarding-flow/onboarding-flow.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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<void>;
}) {
const [password, setPassword] = reactModule.useState('');

return (
<div data-testid="unlock-page">
<input
data-testid="unlock-password-input"
value={password}
onChange={(event) => setPassword(event.target.value)}
/>
<button data-testid="unlock-submit" onClick={() => onSubmit(password)}>
Unlock
</button>
</div>
);
};
});

// Wrapper component that provides proper route context for nested Routes
// OnboardingFlow uses relative paths expecting to be mounted at /onboarding/*
const OnboardingFlowWithRouteContext = () => (
Expand Down Expand Up @@ -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 () => ({
Expand All @@ -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<ResolvedValue = void>() {
let resolvePromise: (
value: ResolvedValue | PromiseLike<ResolvedValue>,
) => void = () => undefined;

const promise = new Promise<ResolvedValue>((resolvedValue) => {
resolvePromise = resolvedValue;
});

return { promise, resolve: resolvePromise };
}

describe('Onboarding Flow', () => {
const mockState = {
metamask: {
Expand Down Expand Up @@ -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(
<OnboardingFlowWithRouteContext />,
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<typeof useSidePanelEnabled>
).mockReturnValue(false);
});

afterEach(() => {
jest.resetAllMocks();
jest.clearAllMocks();
});

it('should route to the default route when completedOnboarding and seedPhraseBackedUp is true', () => {
Expand Down Expand Up @@ -295,26 +391,115 @@ describe('Onboarding Flow', () => {
});

it('should call unlockAndGetSeedPhrase when unlocking with a password', async () => {
const { getByLabelText, getByText } = renderWithProvider(
<OnboardingFlowWithRouteContext />,
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<void>();

(
getIsSeedlessOnboardingFeatureEnabled as jest.MockedFunction<
typeof getIsSeedlessOnboardingFeatureEnabled
>
).mockReturnValue(true);
(
useSidePanelEnabled as jest.MockedFunction<typeof useSidePanelEnabled>
).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<void>();

(
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', () => {
Expand Down
24 changes: 20 additions & 4 deletions ui/pages/onboarding-flow/onboarding-flow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ import {
restoreSocialBackupAndGetSeedPhrase,
createNewVaultAndSyncWithSocial,
setCompletedOnboarding,
setUseSidePanelAsDefault,
setCompletedOnboardingWithSidepanel,
} from '../../store/actions';
import {
getFirstTimeFlowType,
Expand All @@ -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';
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand All @@ -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 });
Expand Down
Loading