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
148 changes: 148 additions & 0 deletions invofi/apps/frontend/src/components/common/SdkErrorBoundary.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import { useState } from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { ContractError, ContractErrorType } from '@invofi/sdk';
import { SdkErrorBoundary } from './SdkErrorBoundary';

/** Throws once on mount, then renders normally after `SdkErrorBoundary` resets it. */
function Bomb({ error, shouldThrow = true }: { error: Error; shouldThrow?: boolean }) {
if (shouldThrow) throw error;
return <div>recovered</div>;
}

describe('SdkErrorBoundary', () => {
afterEach(() => {
vi.restoreAllMocks();
});

it('renders children when nothing throws', () => {
render(
<SdkErrorBoundary>
<div>all good</div>
</SdkErrorBoundary>,
);
expect(screen.getByText('all good')).toBeInTheDocument();
});

it('shows the recovery message and action when a ContractError with recovery is thrown', () => {
vi.spyOn(console, 'error').mockImplementation(() => {});

const err = new ContractError(
5,
ContractErrorType.INSUFFICIENT_BALANCE,
'The account does not have sufficient balance to complete this transaction.',
{ message: 'Add funds to your wallet and try again.', action: 'Add funds' },
);

render(
<SdkErrorBoundary>
<Bomb error={err} />
</SdkErrorBoundary>,
);

expect(screen.getByText('Add funds to your wallet and try again.')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Try again' })).toBeInTheDocument();
});

it('shows a link when the recovery suggestion includes a url', () => {
vi.spyOn(console, 'error').mockImplementation(() => {});

const err = new ContractError(
7,
ContractErrorType.ALREADY_EXISTS,
'A resource with this ID already exists.',
{ message: 'Use a different ID.', action: 'View existing', url: 'https://example.com/lookup' },
);

render(
<SdkErrorBoundary>
<Bomb error={err} />
</SdkErrorBoundary>,
);

const link = screen.getByRole('link', { name: 'View existing' });
expect(link).toHaveAttribute('href', 'https://example.com/lookup');
});

it('falls back to the error message when a ContractError has no recovery suggestion', () => {
vi.spyOn(console, 'error').mockImplementation(() => {});

const err = new ContractError(999999, ContractErrorType.UNKNOWN, 'Contract call failed: mystery error');

render(
<SdkErrorBoundary>
<Bomb error={err} />
</SdkErrorBoundary>,
);

expect(screen.getByText('Contract call failed: mystery error')).toBeInTheDocument();
});

it('renders a graceful generic fallback for a plain (non-SDK) error', () => {
vi.spyOn(console, 'error').mockImplementation(() => {});

render(
<SdkErrorBoundary>
<Bomb error={new Error('boom, totally unrelated to the SDK')} />
</SdkErrorBoundary>,
);

expect(screen.getByText('Something went wrong')).toBeInTheDocument();
expect(screen.getByText('boom, totally unrelated to the SDK')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Try again' })).toBeInTheDocument();
});

it('calls onReset and shows recovered content when "Try again" is clicked after a real error', () => {
vi.spyOn(console, 'error').mockImplementation(() => {});
const onReset = vi.fn();

// Bomb throws on its first render; clicking "Try again" must call
// onReset (which flips shouldThrow to false here, simulating a caller
// that clears whatever caused the error) and then re-render children
// instead of the fallback.
function Wrapper() {
const [shouldThrow, setShouldThrow] = useState(true);
return (
<SdkErrorBoundary
onReset={() => {
onReset();
setShouldThrow(false);
}}
>
<Bomb error={new Error('one-time failure')} shouldThrow={shouldThrow} />
</SdkErrorBoundary>
);
}

render(<Wrapper />);

expect(screen.getByText('Something went wrong')).toBeInTheDocument();
expect(screen.queryByText('recovered')).not.toBeInTheDocument();

fireEvent.click(screen.getByRole('button', { name: 'Try again' }));

expect(onReset).toHaveBeenCalledOnce();
expect(screen.getByText('recovered')).toBeInTheDocument();
expect(screen.queryByText('Something went wrong')).not.toBeInTheDocument();
});

it('supports a custom fallback render prop', () => {
vi.spyOn(console, 'error').mockImplementation(() => {});

const err = new ContractError(2, ContractErrorType.NOT_FOUND, 'No invoice found.');

render(
<SdkErrorBoundary fallback={(error, reset) => (
<div>
<span>custom fallback: {error.message}</span>
<button onClick={reset}>reset-custom</button>
</div>
)}
>
<Bomb error={err} />
</SdkErrorBoundary>,
);

expect(screen.getByText('custom fallback: No invoice found.')).toBeInTheDocument();
});
});
101 changes: 101 additions & 0 deletions invofi/apps/frontend/src/components/common/SdkErrorBoundary.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
'use client';

// ── SdkErrorBoundary — reusable error boundary for @invofi/sdk errors (#223) ──
//
// A narrower, reusable class-component error boundary for wrapping specific
// data-fetching / contract-interaction sections of the UI (a card, a form, a
// table) — NOT a replacement for `src/app/error.tsx`, which remains the
// route-level Next.js error boundary.
//
// When the caught error is a `ContractError` (or `SdkError`) from
// `@invofi/sdk`, the fallback UI surfaces its recovery suggestion
// (`message` / `action` / `url`) so the user gets an actionable next step
// instead of a raw stack trace. Any other error still renders a safe,
// generic fallback rather than crashing the surrounding page.

import { Component, type ErrorInfo, type ReactNode } from 'react';
import { AlertTriangle } from 'lucide-react';
import { ContractError, SdkError } from '@invofi/sdk';
import { Alert, AlertTitle, AlertDescription } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';

interface SdkErrorBoundaryProps {
children: ReactNode;
/** Optional custom fallback renderer, given the caught error and a reset callback. */
fallback?: (error: Error, reset: () => void) => ReactNode;
/** Called when the user retries, before the boundary clears its error state. */
onReset?: () => void;
}

interface SdkErrorBoundaryState {
error: Error | null;
}

/** Default fallback UI shown when no custom `fallback` render prop is supplied. */
function DefaultFallback({ error, onReset }: { error: Error; onReset: () => void }) {
const recovery = error instanceof ContractError ? error.recovery : undefined;
const description = recovery?.message ?? error.message ?? 'An unexpected error occurred. Please try again.';

return (
<Alert variant="destructive" className="max-w-lg">
<AlertTriangle className="h-4 w-4" />
<AlertTitle>Something went wrong</AlertTitle>
<AlertDescription>
<p className="mb-3">{description}</p>
<div className="flex flex-wrap items-center gap-3">
<Button size="sm" variant="outline" onClick={onReset}>
Try again
</Button>
{recovery?.url && (
<a
href={recovery.url}
target="_blank"
rel="noopener noreferrer"
className="text-sm font-medium underline underline-offset-4"
>
{recovery.action ?? 'Learn more'}
</a>
)}
</div>
</AlertDescription>
</Alert>
);
}

/**
* Class-based error boundary for wrapping SDK-driven sections of the UI.
*
* Usage:
* <SdkErrorBoundary>
* <InvoiceTable />
* </SdkErrorBoundary>
*/
export class SdkErrorBoundary extends Component<SdkErrorBoundaryProps, SdkErrorBoundaryState> {
state: SdkErrorBoundaryState = { error: null };

static getDerivedStateFromError(error: Error): SdkErrorBoundaryState {
return { error };
}

componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
// Non-SDK errors (render bugs, etc.) are still contained by the boundary
// — but always logged so they aren't silently swallowed.
const kind = error instanceof SdkError ? error.name : 'Error';
// eslint-disable-next-line no-console
console.error(`[SdkErrorBoundary] caught ${kind}:`, error, errorInfo.componentStack);
}

reset = (): void => {
this.props.onReset?.();
this.setState({ error: null });
};

render(): ReactNode {
const { error } = this.state;
if (error) {
if (this.props.fallback) return this.props.fallback(error, this.reset);
return <DefaultFallback error={error} onReset={this.reset} />;
}
return this.props.children;
}
}
18 changes: 18 additions & 0 deletions invofi/apps/frontend/vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,16 @@ import { defineConfig } from 'vitest/config';
const __dirname = path.dirname(fileURLToPath(import.meta.url));

export default defineConfig({
// The rest of the codebase writes components with the automatic JSX
// runtime (no `import React from 'react'` needed — see e.g.
// src/components/common/EmptyState.tsx), matching Next.js's default. Vite's
// esbuild otherwise falls back to the classic transform (`React.createElement`
// with React expected in scope) since tsconfig.json's `"jsx": "preserve"`
// isn't one of the react-jsx/react-jsxdev values esbuild auto-detects.
// Pin it explicitly so component tests (#223) don't need React imports.
esbuild: {
jsx: 'automatic',
},
test: {
// Unit tests only — the e2e/ directory is Playwright, not Vitest.
include: ['src/**/*.test.{ts,tsx}', 'scripts/**/*.test.mjs'],
Expand All @@ -27,7 +37,15 @@ export default defineConfig({
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
// @invofi/sdk is consumed from source via tsconfig paths (see
// tsconfig.json + next.config.mjs); mirror that here so Vitest can
// resolve it too (#223).
'@invofi/sdk': path.resolve(__dirname, '../sdk/src/index.ts'),
// The SDK's own node_modules isn't installed in CI, so its
// `@stellar/stellar-sdk` import must resolve to this app's copy —
// same reasoning as the webpack alias in next.config.mjs, mirrored
// here for Vitest.
'@stellar/stellar-sdk': path.resolve(__dirname, 'node_modules/@stellar/stellar-sdk'),
},
},
});
9 changes: 5 additions & 4 deletions invofi/apps/sdk/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
validateAssetString,
validateConfigField,
} from './validation';
import { parseContractError } from './errors';

export { SdkValidationError, ErrorCode };

Expand Down Expand Up @@ -145,7 +146,7 @@ export function createInvofiClient(cfg: InvofiClientConfig) {

const simResult = await rpc.simulateTransaction(tx);
if (SorobanRpc.Api.isSimulationError(simResult)) {
throw new Error(`Simulation failed: ${simResult.error}`);
throw parseContractError(simResult.error, 'Simulation failed');
}

tx = SorobanRpc.assembleTransaction(tx, simResult).build();
Expand All @@ -154,7 +155,7 @@ export function createInvofiClient(cfg: InvofiClientConfig) {

const sendResult = await rpc.sendTransaction(signedTx);
if (sendResult.status === 'ERROR') {
throw new Error(`Transaction failed: ${JSON.stringify(sendResult.errorResult)}`);
throw parseContractError(sendResult.errorResult, 'Transaction failed');
}

let getResult = await rpc.getTransaction(sendResult.hash);
Expand All @@ -164,7 +165,7 @@ export function createInvofiClient(cfg: InvofiClientConfig) {
}

if (getResult.status !== 'SUCCESS') {
throw new Error(`Transaction did not succeed: ${getResult.status}`);
throw parseContractError(getResult, `Transaction did not succeed (status: ${getResult.status})`);
}

return getResult.returnValue ?? xdr.ScVal.scvVoid();
Expand Down Expand Up @@ -203,7 +204,7 @@ export function createInvofiClient(cfg: InvofiClientConfig) {

const sim = await rpc.simulateTransaction(tx);
if (SorobanRpc.Api.isSimulationError(sim)) {
throw new Error(`Read failed: ${sim.error}`);
throw parseContractError(sim.error, 'Read failed');
}
if (!SorobanRpc.Api.isSimulationSuccess(sim) || !sim.result) {
throw new Error('Read simulation returned no result');
Expand Down
Loading