Skip to content
Open
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
18 changes: 18 additions & 0 deletions .github/workflows/webview-app-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,24 @@ jobs:
- name: Check Prettier formatting
run: yarn workspace @selfxyz/webview-app fmt

test:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v6
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Build common
run: yarn workspace @selfxyz/common build
- name: Build mobile-sdk-alpha
run: yarn workspace @selfxyz/mobile-sdk-alpha build
- name: Build webview-bridge
run: yarn workspace @selfxyz/webview-bridge build
- name: Run tests
run: yarn workspace @selfxyz/webview-app test
- name: Run webview-bridge tests
run: yarn workspace @selfxyz/webview-bridge test

Comment on lines +68 to +85

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Missing required CI fast-fail guard before test execution

Line 68 onward adds the new test job, but it does not include the required pre-test validation step to detect nested require() patterns. This can reintroduce avoidable OOM-style CI failures later in the job.

As per coding guidelines: ".github/workflows/*.yml: Add a CI fast-fail check step that runs the validation script to detect nested require() patterns before test execution to prevent out-of-memory pipeline failures."

types:
runs-on: ubuntu-latest
timeout-minutes: 15
Expand Down
159 changes: 159 additions & 0 deletions packages/webview-app/src/components/ErrorBoundary.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
// 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.

import type React from 'react';
import { Component } from 'react';

import { bridgeLifecycleAdapter } from '@selfxyz/webview-bridge/adapters';

import { useBridge } from '../providers/BridgeProvider';

interface ErrorBoundaryProps {
children: React.ReactNode;
onDismiss?: () => void;
}

interface ErrorBoundaryState {
hasError: boolean;
error: Error | null;
}

class ErrorBoundaryInner extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
state: ErrorBoundaryState = { hasError: false, error: null };

static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error };
}

componentDidCatch(error: Error, info: React.ErrorInfo): void {
if (import.meta.env.DEV) {
console.error('[ErrorBoundary] Unhandled error:', error, info.componentStack);
} else {
console.error('[ErrorBoundary] Unhandled error');
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

handleRetry = (): void => {
this.setState({ hasError: false, error: null });
};

handleDismiss = (): void => {
this.props.onDismiss?.();
};

render(): React.ReactNode {
if (!this.state.hasError) {
return this.props.children;
}

return (
<div style={styles.container}>
<div style={styles.content}>
<div style={styles.icon}>!</div>
<h1 style={styles.title}>Something went wrong</h1>
<p style={styles.message}>An unexpected error occurred. You can try again or close this screen.</p>
{import.meta.env.DEV && this.state.error && <pre style={styles.errorDetail}>{this.state.error.message}</pre>}
<button type="button" onClick={this.handleRetry} style={styles.primaryButton}>
Try again
</button>
<button type="button" onClick={this.handleDismiss} style={styles.secondaryButton}>
Close
</button>
</div>
</div>
);
}
}

export const ErrorBoundary: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const bridge = useBridge();

const handleDismiss = (): void => {
const lifecycle = bridgeLifecycleAdapter(bridge);
lifecycle.dismiss({ reason: 'user_cancel' });
};

return <ErrorBoundaryInner onDismiss={handleDismiss}>{children}</ErrorBoundaryInner>;
};

const styles = {
container: {
display: 'flex',
flexDirection: 'column' as const,
alignItems: 'center',
justifyContent: 'center',
height: '100vh',
width: '100%',
padding: 24,
backgroundColor: '#fff',
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
},
content: {
display: 'flex',
flexDirection: 'column' as const,
alignItems: 'center',
maxWidth: 320,
textAlign: 'center' as const,
},
icon: {
width: 48,
height: 48,
borderRadius: 24,
backgroundColor: '#FEE2E2',
color: '#DC2626',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 24,
fontWeight: 700,
marginBottom: 16,
},
title: {
fontSize: 20,
fontWeight: 600,
color: '#111',
margin: '0 0 8px',
},
message: {
fontSize: 14,
color: '#666',
lineHeight: 1.5,
margin: '0 0 24px',
},
errorDetail: {
fontSize: 11,
color: '#999',
backgroundColor: '#f5f5f5',
borderRadius: 8,
padding: 12,
width: '100%',
overflow: 'auto' as const,
maxHeight: 80,
marginBottom: 24,
textAlign: 'left' as const,
},
primaryButton: {
width: '100%',
padding: '14px 24px',
fontSize: 16,
fontWeight: 600,
color: '#fff',
backgroundColor: '#111',
border: 'none',
borderRadius: 12,
cursor: 'pointer',
marginBottom: 12,
},
secondaryButton: {
width: '100%',
padding: '14px 24px',
fontSize: 16,
fontWeight: 600,
color: '#666',
backgroundColor: 'transparent',
border: '1px solid #ddd',
borderRadius: 12,
cursor: 'pointer',
},
};
Comment on lines +80 to +159

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Raw hex values instead of design tokens

The styles object uses raw hex literals throughout (#fff, #FEE2E2, #DC2626, #111, #666, #f5f5f5, #999, #ddd) instead of tokens from @selfxyz/euclid. Per the project rules, shared color/font/spacing tokens should be used instead of raw hex values in UI code. For example, colors.red600, colors.black, colors.grey600, etc. should replace the raw values where equivalents exist in the euclid token set.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

5 changes: 4 additions & 1 deletion packages/webview-app/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import React from 'react';
import { createRoot } from 'react-dom/client';

import { App } from './App';
import { ErrorBoundary } from './components/ErrorBoundary';
import { BridgeProvider } from './providers/BridgeProvider';

import './fonts.css';
Expand All @@ -19,7 +20,9 @@ createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<div style={{ display: 'flex', flex: 1, height: '100vh', width: '100%', maxWidth: 430, margin: '0 auto' }}>
<BridgeProvider>
<App />
<ErrorBoundary>
<App />
</ErrorBoundary>
</BridgeProvider>
</div>
</React.StrictMode>,
Expand Down
91 changes: 91 additions & 0 deletions packages/webview-app/tests/components/ErrorBoundary.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
// 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 { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import { cleanup, fireEvent, render, screen } from '@testing-library/react';

const dismissMock = vi.fn();

vi.mock('../../src/providers/BridgeProvider', () => ({
useBridge: () => ({}),
}));

vi.mock('@selfxyz/webview-bridge/adapters', () => ({
bridgeLifecycleAdapter: () => ({
dismiss: dismissMock,
}),
}));

// Import after mocks
const { ErrorBoundary } = await import('../../src/components/ErrorBoundary');

const ThrowingChild: React.FC<{ shouldThrow: boolean }> = ({ shouldThrow }) => {
if (shouldThrow) throw new Error('test crash');
return <div>healthy content</div>;
};

describe('ErrorBoundary', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(console, 'error').mockImplementation(() => {});
});

afterEach(cleanup);
Comment on lines +33 to +38

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Restore console.error spy after each test to avoid cross-test leakage

Line 35 installs a global spy, but Line 38 does not restore it. This can leak mocked console behavior into subsequent tests.

Suggested fix
-  afterEach(cleanup);
+  afterEach(() => {
+    cleanup();
+    vi.restoreAllMocks();
+  });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(cleanup);
beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});


it('renders children when no error', () => {
render(
<ErrorBoundary>
<ThrowingChild shouldThrow={false} />
</ErrorBoundary>,
);
expect(screen.getByText('healthy content')).toBeDefined();
});

it('catches errors and shows fallback UI', () => {
render(
<ErrorBoundary>
<ThrowingChild shouldThrow={true} />
</ErrorBoundary>,
);
expect(screen.getByText('Something went wrong')).toBeDefined();
expect(screen.getByRole('button', { name: /try again/i })).toBeDefined();
expect(screen.getByRole('button', { name: /close/i })).toBeDefined();
});

it('resets error state when retry is clicked', () => {
let shouldThrow = true;
const Conditional: React.FC = () => {
if (shouldThrow) throw new Error('test crash');
return <div>recovered</div>;
};

render(
<ErrorBoundary>
<Conditional />
</ErrorBoundary>,
);

expect(screen.getByText('Something went wrong')).toBeDefined();

shouldThrow = false;
fireEvent.click(screen.getByRole('button', { name: /try again/i }));

expect(screen.getByText('recovered')).toBeDefined();
});

it('calls dismiss on close', () => {
render(
<ErrorBoundary>
<ThrowingChild shouldThrow={true} />
</ErrorBoundary>,
);

fireEvent.click(screen.getByRole('button', { name: /close/i }));
expect(dismissMock).toHaveBeenCalledWith({ reason: 'user_cancel' });
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,13 @@ vi.mock('../../../src/providers/SelfClientProvider', () => ({
}),
}));

const mockDocumentStore = {
const mockDocumentStore = vi.hoisted(() => ({
addDocument: vi.fn(),
clear: vi.fn(),
hasDocuments: vi.fn().mockReturnValue(false),
getCatalog: vi.fn().mockReturnValue({ documents: [] }),
subscribe: vi.fn().mockReturnValue(() => {}),
};
}));

vi.mock('../../../src/utils/mockDocumentStore', () => ({
mockDocumentStore,
Expand Down Expand Up @@ -180,7 +180,8 @@ const renderRoutes = (initialEntries: string[]) =>
);

const expectLocation = (expected: string) => {
expect(screen.getByTestId('location').textContent).toBe(expected);
const locations = screen.getAllByTestId('location');
expect(locations.at(-1)?.textContent).toBe(expected);
};

describe('WV-16 settings screens', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,9 @@ vi.mock('@selfxyz/webview-bridge/adapters', () => ({
}));

vi.mock('@selfxyz/euclid', () => ({
borderRadius: {
mdd: 14,
},
colors: {
black: '#000',
red600: '#f00',
Expand Down Expand Up @@ -99,6 +102,7 @@ vi.mock('@selfxyz/euclid', () => ({
mdLg: 16,
xlLg: 24,
},
RecoveryPhrase: () => <div>Recovery phrase grid</div>,
ZapShieldIcon: () => null,
SettingsViewScreen: ({ sections }: { sections: Array<{ items: Array<{ label: string; onPress: () => void }> }> }) => (
<div>
Expand Down Expand Up @@ -320,17 +324,12 @@ describe('recovery support screens', () => {
});
});

it('carries returnTo through recovery success and resumes the caller route', async () => {
it('carries returnTo and resumes the caller route directly after recovery', async () => {
renderRoutes(['/recovery/phrase-input?returnTo=%2Ftunnel%2Fproof%2Fgenerating']);

fireEvent.click(screen.getByRole('button', { name: /fill valid phrase/i }));
fireEvent.click(screen.getByRole('button', { name: /^continue$/i }));

await waitFor(() => {
expectLocation('/recovery/success');
});

fireEvent.click(screen.getByRole('button', { name: /finish recovery/i }));
await waitFor(() => {
expectLocation('/tunnel/proof/generating');
});
Expand Down Expand Up @@ -450,11 +449,6 @@ describe('recovery support screens', () => {

fireEvent.click(screen.getByRole('button', { name: /fill valid phrase/i }));
fireEvent.click(screen.getByRole('button', { name: /^continue$/i }));
await waitFor(() => {
expectLocation('/recovery/success');
});

fireEvent.click(screen.getByRole('button', { name: /finish recovery/i }));
await waitFor(() => {
expectLocation('/tunnel/proof/generating');
});
Expand Down
Loading
Loading