>();
+
+vi.mock('../../../src/providers/SelfClientProvider', () => ({
+ useSelfClient: () => ({
+ analytics,
+ haptic,
+ }),
+}));
+
+vi.mock('../../../src/providers/BridgeProvider', () => ({
+ useBridge: () => ({}),
+}));
+
+vi.mock('@selfxyz/webview-bridge/adapters', () => ({
+ bridgeStorageAdapter: () => ({
+ get: storageGet,
+ set: vi.fn().mockResolvedValue(undefined),
+ remove: vi.fn().mockResolvedValue(undefined),
+ }),
+}));
+
+vi.mock('@selfxyz/euclid', () => ({
+ createSafeAreaProps: ({ top, bottom }: { top: number; bottom: number }) => ({
+ insets: { top, bottom, left: 0, right: 0 },
+ safeArea: { top, bottom, left: 0, right: 0 },
+ }),
+ RecoveryPhraseScreen: ({
+ words,
+ variant,
+ onReveal,
+ onCopy,
+ }: {
+ words?: string[];
+ variant: string;
+ onReveal: () => void;
+ onCopy: () => void;
+ }) => (
+
+
{variant}
+
{words?.join(' ') ?? ''}
+
+
+
+ ),
+}));
+
+describe('RecoveryPhraseScreen', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ storageGet.mockResolvedValue(null);
+ Object.assign(navigator, {
+ clipboard: {
+ writeText: vi.fn().mockResolvedValue(undefined),
+ },
+ });
+ });
+
+ afterEach(() => {
+ cleanup();
+ });
+
+ it('reveals placeholders when secure storage is empty', async () => {
+ render(
+
+
+ ,
+ );
+
+ fireEvent.click(screen.getByRole('button', { name: /reveal phrase/i }));
+
+ await waitFor(() => {
+ expect(screen.getByTestId('variant').textContent).toBe('revealed');
+ expect(screen.getByTestId('words').textContent).toBe('');
+ });
+ });
+
+ it('copies the resolved words to the clipboard', async () => {
+ storageGet.mockResolvedValue(JSON.stringify({ phrase: 'alpha beta gamma' }));
+
+ render(
+
+
+ ,
+ );
+
+ fireEvent.click(screen.getByRole('button', { name: /reveal phrase/i }));
+
+ await waitFor(() => {
+ expect(screen.getByTestId('words').textContent).toBe('alpha beta gamma');
+ });
+
+ fireEvent.click(screen.getByRole('button', { name: /copy phrase/i }));
+
+ await waitFor(() => {
+ expect(navigator.clipboard.writeText).toHaveBeenCalledWith('alpha beta gamma');
+ expect(screen.getByTestId('variant').textContent).toBe('copied');
+ });
+ });
+
+ it('does not switch to copied when clipboard write fails', async () => {
+ storageGet.mockResolvedValue(JSON.stringify({ phrase: 'alpha beta gamma' }));
+ Object.assign(navigator, {
+ clipboard: {
+ writeText: vi.fn().mockRejectedValue(new Error('denied')),
+ },
+ });
+
+ render(
+
+
+ ,
+ );
+
+ fireEvent.click(screen.getByRole('button', { name: /reveal phrase/i }));
+
+ await waitFor(() => {
+ expect(screen.getByTestId('words').textContent).toBe('alpha beta gamma');
+ });
+
+ fireEvent.click(screen.getByRole('button', { name: /copy phrase/i }));
+
+ await waitFor(() => {
+ expect(navigator.clipboard.writeText).toHaveBeenCalledWith('alpha beta gamma');
+ expect(screen.getByTestId('variant').textContent).toBe('revealed');
+ expect(haptic.trigger).toHaveBeenCalledWith('error');
+ });
+ });
+});
diff --git a/packages/webview-app/tests/screens/recovery/recoverySupportScreens.test.tsx b/packages/webview-app/tests/screens/recovery/recoverySupportScreens.test.tsx
new file mode 100644
index 000000000..e43676919
--- /dev/null
+++ b/packages/webview-app/tests/screens/recovery/recoverySupportScreens.test.tsx
@@ -0,0 +1,231 @@
+// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
+// SPDX-License-Identifier: BUSL-1.1
+// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
+
+// @vitest-environment jsdom
+
+import type React from 'react';
+import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { SecurityScreen } from '../../../src/screens/account/SecurityScreen';
+import { SettingsScreen } from '../../../src/screens/account/SettingsScreen';
+import { BackupMethodPickerScreen } from '../../../src/screens/recovery/BackupMethodPickerScreen';
+import { LaunchRecoveryScreen } from '../../../src/screens/recovery/LaunchRecoveryScreen';
+import { RecoveryPhraseScreen } from '../../../src/screens/recovery/RecoveryPhraseScreen';
+import { RecoverySuccessScreen } from '../../../src/screens/recovery/RecoverySuccessScreen';
+import { SecretPhraseInputScreen } from '../../../src/screens/recovery/SecretPhraseInputScreen';
+
+import { cleanup, fireEvent, render, screen } from '@testing-library/react';
+
+const analytics = { trackEvent: vi.fn() };
+const haptic = { trigger: vi.fn() };
+const lifecycle = { dismiss: vi.fn() };
+
+vi.mock('../../../src/providers/SelfClientProvider', () => ({
+ useSelfClient: () => ({
+ analytics,
+ haptic,
+ lifecycle,
+ }),
+}));
+
+vi.mock('../../../src/providers/BridgeProvider', () => ({
+ useBridge: () => ({}),
+}));
+
+vi.mock('@selfxyz/webview-bridge/adapters', () => ({
+ bridgeStorageAdapter: () => ({
+ get: vi.fn().mockResolvedValue(null),
+ set: vi.fn().mockResolvedValue(undefined),
+ remove: vi.fn().mockResolvedValue(undefined),
+ }),
+}));
+
+vi.mock('@selfxyz/euclid', () => ({
+ createSafeAreaProps: ({ top, bottom }: { top: number; bottom: number }) => ({
+ insets: { top, bottom, left: 0, right: 0 },
+ safeArea: { top, bottom, left: 0, right: 0 },
+ }),
+ ChatStrokeIcon: () => null,
+ CloudKeyIcon: () => null,
+ CodeIcon: () => null,
+ DocumentDetailsIcon: () => null,
+ LeftArrowIcon: () => null,
+ LockIcon: () => null,
+ NotificationIcon: () => null,
+ QuestionCircleStrokeIcon: () => null,
+ SelfLogo: () => null,
+ ShareIcon: () => null,
+ ZapShieldIcon: () => null,
+ SettingsViewScreen: ({ sections }: { sections: Array<{ items: Array<{ label: string; onPress: () => void }> }> }) => (
+
+ {sections.flatMap(section =>
+ section.items.map(item => (
+
+ )),
+ )}
+
+ ),
+ SecurityScreen: ({
+ onBackupAccount,
+ onRestoreAccount,
+ onRevealRecoveryPhrase,
+ }: {
+ onBackupAccount: () => void;
+ onRestoreAccount: () => void;
+ onRevealRecoveryPhrase: () => void;
+ }) => (
+
+
+
+
+
+ ),
+ BackupMethodPickerScreen: ({ options }: { options: Array<{ label: string; onPress: () => void }> }) => (
+
+ {options.map(option => (
+
+ ))}
+
+ ),
+ RecoveryPhraseScreen: ({ onReveal, onCopy }: { onReveal: () => void; onCopy: () => void }) => (
+
+
+
+
+ ),
+ LaunchRecoveryScreen: ({
+ onEnterRecoveryPhrase,
+ onClose,
+ }: {
+ onEnterRecoveryPhrase: () => void;
+ onClose: () => void;
+ }) => (
+
+
+
+
+ ),
+ SecretPhraseInputScreen: ({ onSubmit }: { onSubmit: (words: string[]) => void }) => (
+
+
+
+
+ ),
+ RecoverySuccessScreen: ({ onClose }: { onClose: () => void }) => (
+
+ ),
+}));
+
+const LocationDisplay: React.FC = () => {
+ const location = useLocation();
+ return {location.pathname}
;
+};
+
+const renderRoutes = (initialEntries: string[]) =>
+ render(
+
+
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+
+
+ ,
+ );
+
+describe('recovery support screens', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ afterEach(() => {
+ cleanup();
+ });
+
+ const expectLocation = (expected: string) => {
+ expect(screen.getByTestId('location').textContent).toBe(expected);
+ };
+
+ it('stitches settings through security to recovery phrase backup flow', () => {
+ renderRoutes(['/settings']);
+
+ fireEvent.click(screen.getByRole('button', { name: /security/i }));
+ expectLocation('/settings/security');
+
+ fireEvent.click(screen.getByRole('button', { name: /back up account/i }));
+ expectLocation('/settings/backup');
+
+ fireEvent.click(screen.getByRole('button', { name: /recovery phrase/i }));
+ expectLocation('/settings/recovery-phrase');
+ });
+
+ it('stitches settings through security to recovery restore success flow', () => {
+ renderRoutes(['/settings']);
+
+ fireEvent.click(screen.getByRole('button', { name: /security/i }));
+ expectLocation('/settings/security');
+
+ fireEvent.click(screen.getByRole('button', { name: /restore account/i }));
+ expectLocation('/recovery');
+
+ fireEvent.click(screen.getByRole('button', { name: /enter recovery phrase/i }));
+ expectLocation('/recovery/phrase-input');
+
+ fireEvent.click(screen.getByRole('button', { name: /submit valid phrase/i }));
+ expectLocation('/recovery/success');
+ });
+
+ it('rejects an invalid mnemonic and stays on phrase input', () => {
+ renderRoutes(['/recovery/phrase-input']);
+ expectLocation('/recovery/phrase-input');
+
+ fireEvent.click(screen.getByRole('button', { name: /submit invalid phrase/i }));
+ expectLocation('/recovery/phrase-input');
+
+ expect(haptic.trigger).toHaveBeenCalledWith('error');
+ expect(analytics.trackEvent).toHaveBeenCalledWith('recovery_phrase_rejected', { wordCount: 3 });
+ });
+
+ it('launch recovery close returns to previous screen', () => {
+ renderRoutes(['/settings/security', '/recovery']);
+ expectLocation('/recovery');
+
+ fireEvent.click(screen.getByRole('button', { name: /close recovery/i }));
+ expectLocation('/settings/security');
+ });
+});
diff --git a/yarn.lock b/yarn.lock
index 56e4be6c2..0fb2db91f 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -10784,26 +10784,26 @@ __metadata:
languageName: unknown
linkType: soft
-"@selfxyz/euclid-core@npm:1.2.6, @selfxyz/euclid-core@npm:^1.2.6":
- version: 1.2.6
- resolution: "@selfxyz/euclid-core@npm:1.2.6"
+"@selfxyz/euclid-core@npm:1.3.0, @selfxyz/euclid-core@npm:^1.3.0":
+ version: 1.3.0
+ resolution: "@selfxyz/euclid-core@npm:1.3.0"
peerDependencies:
react: ">=18.2.0"
- checksum: 10c0/0496da70a4eb5e3b7bbae51f2379f8080ed21950eac6e09c3ad9a39020399ef0a7065798d626e86ac187a34ee40c04e0bac1d8186aa1507900c83b667ac6816a
+ checksum: 10c0/346831b2e2128014a1143f2af079229c40bfe64a622ef54600f0d927a8b8f78e8ad624fd4805488e03fbc2504171313fcc62c4123970afa30597c7380c817c3b
languageName: node
linkType: hard
-"@selfxyz/euclid@npm:1.2.6":
- version: 1.2.6
- resolution: "@selfxyz/euclid@npm:1.2.6"
+"@selfxyz/euclid@npm:1.3.0":
+ version: 1.3.0
+ resolution: "@selfxyz/euclid@npm:1.3.0"
dependencies:
"@lottiefiles/dotlottie-react": "npm:^0.18.4"
- "@selfxyz/euclid-core": "npm:^1.2.6"
+ "@selfxyz/euclid-core": "npm:^1.3.0"
lottie-react: "npm:^2.4.1"
peerDependencies:
react: ">=18.2.0"
react-dom: ">=18.2.0"
- checksum: 10c0/af56ea1abc20645dbe58f29d062ab4e2686f626de92fb5626cfbe7971b4ec31926edf8e0d598dd173a4f1b9749e6ccf690e3189e7694178e2653a2ddff195f4d
+ checksum: 10c0/056e53cfa6566056fc107fb553157de586dcdd384eedf101316b11b08d39d5800226321d53e119e38160977ef346632f6db16c27ad67f003d0bf085aed8a0f48
languageName: node
linkType: hard
@@ -11260,8 +11260,9 @@ __metadata:
version: 0.0.0-use.local
resolution: "@selfxyz/webview-app@workspace:packages/webview-app"
dependencies:
- "@selfxyz/euclid": "npm:1.2.6"
- "@selfxyz/euclid-core": "npm:1.2.6"
+ "@scure/bip39": "npm:^1.6.0"
+ "@selfxyz/euclid": "npm:1.3.0"
+ "@selfxyz/euclid-core": "npm:1.3.0"
"@selfxyz/mobile-sdk-alpha": "workspace:^"
"@selfxyz/webview-bridge": "workspace:^"
"@sumsub/websdk": "npm:^2.0.0"