diff --git a/docs/testing-conventions.md b/docs/testing-conventions.md new file mode 100644 index 00000000..a44bf63c --- /dev/null +++ b/docs/testing-conventions.md @@ -0,0 +1,163 @@ +# Testing Conventions + +How tests are structured in this repo, how to mock the seams (React Query, +wallet, browser APIs), and how to set up an integration test. For +util-specific guidance see the [Utils Testing Guide](./utils-testing-guide.md); +for what hooks should do on failure paths (and therefore what your tests +should assert), see [Error Handling in Hooks](./error-handling-in-hooks.md). + +The runner is **Vitest** (`vitest.config.ts`: jsdom environment, globals +enabled, setup in `src/test/setup.ts`). Run everything with `pnpm test`, or a +single file with `pnpm test `. + +## File naming and co-location + +Tests live in a `__tests__/` folder next to the code they exercise: + +``` +src/hooks/ + ├─ useFormatXlm.ts + └─ __tests__/ + └─ useFormatXlm.test.ts +src/pages/ + ├─ LandingPage.tsx + └─ __tests__/ + ├─ LandingPage.holdings.test.tsx ← unit-ish page test + └─ LandingPage.sellFlow.integration.test.tsx ← integration test +``` + +- **Unit tests**: `.test.ts` / `.test.tsx`. +- **Integration tests**: `..integration.test.tsx` — one flow + per file, named after the feature under test. Components may also co-locate + a test directly beside the file (e.g. + `src/components/common/__tests__/TradeDialog.clamp.integration.test.tsx`). +- Reference the issue number in the top-level `describe` when the test + exists to lock in an issue's acceptance criteria, e.g. + `describe('LandingPage sell flow end-to-end (#644)', …)`. + +## Mocking React Query responses + +There are two established patterns — pick based on what the test is about. + +**1. Mock the service, keep React Query real** (preferred for integration +tests — caching, invalidation and optimistic updates stay honest): + +```tsx +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { courseService } from '@/services/course.service'; + +vi.mock('@/services/course.service', () => ({ + courseService: { getCourses: vi.fn() }, +})); +const mockGetCourses = vi.mocked(courseService.getCourses); + +const renderPage = () => + render( + + + + + + ); + +// in the test: +mockGetCourses.mockResolvedValue([…fixtures…]); +``` + +Always create a **fresh `QueryClient` per render** (never share one between +tests — cached data leaks across cases) and disable retries so failure-path +tests don't wait on backoff. + +**2. Mock the hook module wholesale** (for unit tests where query machinery +is noise): + +```tsx +vi.mock('@/hooks/useWallet', () => ({ + useTradeMutation: () => ({ mutateAsync: vi.fn(), isPending: false }), + useWalletHoldings: () => ({ data: [] }), +})); +``` + +Anything rendering a component that calls `useQuery`/`useMutation` **must** +be wrapped in a `QueryClientProvider` unless every such hook is mocked out — +a missing provider fails with `No QueryClient set`. + +## Mocking wallet connection state + +Wallet state flows through the hooks in `src/hooks/useWallet.ts` +(`useWalletHoldings`, `useWalletActivity`, `useTradeMutation`). Component +tests mock at that seam: + +```tsx +vi.mock('@/hooks/useWallet', () => ({ + // "connected wallet holding 2 keys of creator-a" + useWalletHoldings: () => ({ + data: [{ creatorId: 'creator-a', quantity: 2, priceStroops: 500_000, price: 0.05, pending: false }], + }), + useTradeMutation: () => ({ mutateAsync: vi.fn(), isPending: false }), +})); +``` + +For full-flow tests, prefer **not** mocking `useWallet` at all: the demo +wallet seeds the featured creator with 3 held keys, and the real +`useTradeMutation` exercises the optimistic-update and invalidation paths +(see `LandingPage.sellFlow.integration.test.tsx`). Trade submissions resolve +on real timers (~1.2s), so assert with +`waitFor(…, { timeout: 5000 })` rather than fake timers. + +## Integration test setup + +The standard shell for a page-level integration test: + +1. **Providers**: wrap in `QueryClientProvider` (fresh client) and + `MemoryRouter` — pages use react-router hooks. +2. **Service mocks**: `vi.mock('@/services/course.service')` and resolve + fixture data per test. +3. **Toast sink**: mock `@/utils/toast.util` and assert on + `showToast.success` / `error` / `transactionSuccess` calls instead of + scraping toast DOM (no `` is mounted in tests). +4. **Presentation mocks** (copy from an existing integration test): + `framer-motion` (pass-through elements), `@/components/common/CreatorCard` + (lightweight article), `StellarConnectionQualityBadge`, + `FeaturedCreatorAudienceChip`, and network/staleness hooks + (`useNetworkMismatch`, `useStaleData`) pinned to healthy values. +5. **Browser API stubs**, in `beforeEach`: + - `matchMedia` — jsdom doesn't implement it; use the `mockMatchMedia` + helper pattern found in the page tests. + - `localStorage` / `sessionStorage` — newer Node versions (v22+ + WebStorage, default in v25) shadow jsdom's storage with a global that + has no working methods, so `window.localStorage.clear()` throws. New + suites should install an in-memory stub (see `installStorageStub` in + `LandingPage.sellFlow.integration.test.tsx`) instead of touching the + global directly. +6. **Cleanup**: `afterEach(cleanup)` — automatic unmount is not enabled. + +## Available test utilities + +There is deliberately no shared custom `render` yet; each suite composes its +own providers. The reusable pieces to copy today: + +| Utility | Where | What it does | +|---|---|---| +| `src/test/setup.ts` | global setup | registers `@testing-library/jest-dom` matchers | +| `mockMatchMedia()` | page test files | stubs `window.matchMedia` for jsdom | +| `installStorageStub()` | `LandingPage.sellFlow.integration.test.tsx` | Node-version-proof localStorage/sessionStorage stub | +| `makeQueryClient()` | `LandingPage.sort.integration.test.tsx` | fresh `QueryClient` with retries disabled | +| `confirmTrade(side, amount)` | `LandingPage.holdingsSellBalanceUpdate.integration.test.tsx` | drives the trade dialog: open → amount → confirm | +| `dispatchRejection(reason)` | `unhandledRejectionLogger.test.ts` | synthesizes an unhandled-rejection event | + +If you find yourself copying more than two of these into a new file, that is +the signal to promote them into `src/test/` as shared utilities — do it in +the same PR. + +## What good assertions look like here + +- Assert **user-visible outcomes** (rendered text, toast calls, holdings + rows), not internal state. +- For flows with optimistic updates, assert both the intermediate state + (pending) and the settled state where practical. +- Error paths deserve their own tests — see + [Error Handling in Hooks](./error-handling-in-hooks.md) for the expected + failure behaviour to pin down. diff --git a/src/hooks/__tests__/useFormatXlm.test.ts b/src/hooks/__tests__/useFormatXlm.test.ts index 31e860be..74c2a777 100644 --- a/src/hooks/__tests__/useFormatXlm.test.ts +++ b/src/hooks/__tests__/useFormatXlm.test.ts @@ -101,4 +101,65 @@ describe('useFormatXlm', () => { expect(result.current.format(10_000_000, { decimals: 0 })).toBe('1'); }); }); + describe('bigint inputs (#645)', () => { + it('formats a safe-range bigint identically to the equivalent number', () => { + expect(formatXlm(15_000_000n)).toBe(formatXlm(15_000_000)); + expect(formatXlm(500_000n)).toBe(formatXlm(500_000)); + expect(formatXlm(70_000_000_000n)).toBe(formatXlm(70_000_000_000)); + }); + + it('respects the decimals option for bigint inputs', () => { + expect(formatXlm(10_000_000n, { decimals: 0 })).toBe( + formatXlm(10_000_000, { decimals: 0 }) + ); + expect(formatXlm(15_000_000n, { decimals: 7 })).toBe( + formatXlm(15_000_000, { decimals: 7 }) + ); + }); + + it('formats a bigint above Number.MAX_SAFE_INTEGER without precision loss', () => { + // 9_007_199_254_740_993 is MAX_SAFE_INTEGER + 2; as a number it + // silently rounds to ...992, so the final displayed digit proves + // whether the bigint path avoided float conversion. + const stroops = 9_007_199_254_740_993n; + const result = formatXlm(stroops, { decimals: 7 }); + + const expectedWhole = new Intl.NumberFormat(undefined, { + useGrouping: true, + }).format(900_719_925n); + expect(result.startsWith(expectedWhole)).toBe(true); + expect(result.endsWith('4740993')).toBe(true); + }); + + it('never renders scientific notation for very large bigints', () => { + const result = formatXlm(123_456_789_012_345_678_901_234_567_890n); + expect(result).not.toMatch(/e/i); + }); + + it('keeps every digit of a very large bigint', () => { + // 12_345_678_901_234_567_890 stroops = 1_234_567_890_123.4567890 XLM + const result = formatXlm(12_345_678_901_234_567_890n, { decimals: 7 }); + const digitsOnly = result.replace(/[^0-9]/g, ''); + expect(digitsOnly).toBe('12345678901234567890'); + }); + + it('formats 0n as 0.00', () => { + expect(formatXlm(0n)).toBe('0.00'); + }); + + it('formats a negative bigint as a negative formatted string', () => { + expect(formatXlm(-15_000_000n)).toBe(`-${formatXlm(15_000_000n)}`); + expect(formatXlm(-15_000_000n)).toBe(formatXlm(-15_000_000)); + }); + + it('does not emit a negative sign when a negative amount rounds to zero', () => { + // -1 stroop rounds to 0.00 at 2 decimals — "-0.00" would be wrong + expect(formatXlm(-1n)).toBe('0.00'); + }); + + it('hook format function accepts bigint inputs', () => { + const { result } = renderHook(() => useFormatXlm()); + expect(result.current.format(15_000_000n)).toBe(formatXlm(15_000_000)); + }); + }); }); diff --git a/src/hooks/useFormatXlm.ts b/src/hooks/useFormatXlm.ts index 88431c1f..c2c869bf 100644 --- a/src/hooks/useFormatXlm.ts +++ b/src/hooks/useFormatXlm.ts @@ -5,23 +5,74 @@ export interface FormatXlmOptions { decimals?: number; } +/** + * Formats a bigint stroop amount without ever passing through `number`, + * so values beyond Number.MAX_SAFE_INTEGER keep every digit. The whole-XLM + * part is formatted by Intl (which accepts bigint natively) for locale + * grouping; the fractional digits are computed with integer arithmetic and + * joined with the locale's decimal separator so output matches the number + * path in any locale. + */ +function formatBigintXlm(stroops: bigint, decimals: number): string { + const negative = stroops < 0n; + const abs = negative ? -stroops : stroops; + const stroopsPerXlm = BigInt(STROOPS_PER_XLM); + const scale = 10n ** BigInt(decimals); + + // Round half up on the last displayed digit, mirroring Intl's rounding + const scaled = (abs * scale + stroopsPerXlm / 2n) / stroopsPerXlm; + const whole = scaled / scale; + const fraction = scaled % scale; + + const wholeStr = new Intl.NumberFormat(undefined, { + useGrouping: true, + }).format(whole); + + const sign = negative && scaled !== 0n ? '-' : ''; + + if (decimals === 0) { + return `${sign}${wholeStr}`; + } + + const decimalSeparator = + new Intl.NumberFormat(undefined, { minimumFractionDigits: 1 }) + .formatToParts(1.1) + .find(part => part.type === 'decimal')?.value ?? '.'; + + const fractionStr = fraction.toString().padStart(decimals, '0'); + + return `${sign}${wholeStr}${decimalSeparator}${fractionStr}`; +} + /** * Converts a stroop amount to a formatted XLM string. * + * Accepts both `number` and `bigint` stroops. Bigint inputs are formatted + * with integer arithmetic end to end, so amounts above + * `Number.MAX_SAFE_INTEGER` render with full precision and never fall back + * to scientific notation. Negative amounts (either type) format with a + * leading minus sign. + * * @param stroops - Amount in stroops (1 XLM = 10,000,000 stroops) * @param options - Formatting options * @returns Formatted XLM string, e.g. "1.50" for 15,000,000 stroops * * @example * formatXlm(10_000_000) // "1.00" + * formatXlm(10_000_000n) // "1.00" * formatXlm(10_000_000, { decimals: 0 }) // "1" * formatXlm(15_000_000, { decimals: 7 }) // "1.5000000" */ export function formatXlm( - stroops: number, + stroops: number | bigint, options: FormatXlmOptions = {} ): string { const { decimals = 2 } = options; + + if (typeof stroops === 'bigint') { + return formatBigintXlm(stroops, decimals); + } + const xlm = stroops / STROOPS_PER_XLM; return new Intl.NumberFormat(undefined, { diff --git a/src/main.tsx b/src/main.tsx index 98c2e6cb..69a34532 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -2,6 +2,9 @@ import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import './index.css'; import App from './App.tsx'; +import { registerUnhandledRejectionLogger } from './utils/unhandledRejectionLogger'; + +registerUnhandledRejectionLogger(); createRoot(document.getElementById('root')!).render( diff --git a/src/pages/LandingPage.tsx b/src/pages/LandingPage.tsx index 36448de2..011fcb42 100644 --- a/src/pages/LandingPage.tsx +++ b/src/pages/LandingPage.tsx @@ -87,6 +87,7 @@ const FEATURED_CREATOR_FOLLOWER_COUNT: number | null = null; const FEATURED_CREATOR_KEY_HOLDER_COUNT = 0; const FEATURED_CREATOR_STELLAR_ADDRESS = 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5'; +const FEATURED_CREATOR_NAME = 'Alex Rivers'; // Fallback demo data in case API fails const DEMO_CREATORS: Course[] = [ @@ -817,7 +818,7 @@ function LandingPage() { await new Promise(resolve => window.setTimeout(resolve, 250)); showToast.transactionSuccess( 'Trade confirmed', - `Holdings refreshed: -${formatNumber(amount)} keys.` + `Sold ${formatNumber(amount)} key${amount === 1 ? '' : 's'} from ${FEATURED_CREATOR_NAME}` ); } setTradeDialogOpen(false); @@ -1730,7 +1731,7 @@ function LandingPage() { ({ + courseService: { getCourses: vi.fn() }, +})); + +vi.mock('@/utils/toast.util', () => ({ + default: { + message: vi.fn(), + success: vi.fn(), + error: vi.fn(), + loading: vi.fn(), + transactionSuccess: vi.fn(), + }, +})); + +vi.mock('@/hooks/useNetworkMismatch', () => ({ + useNetworkMismatch: () => ({ + isMismatch: false, + expectedChainName: 'Stellar Testnet', + }), +})); + +vi.mock('@/hooks/useStaleData', () => ({ + useStaleData: () => ({ + stale: false, + ageMs: 0, + msUntilStale: 60_000, + revalidate: vi.fn(), + }), +})); + +vi.mock('@/components/common/StellarConnectionQualityBadge', async () => { + const React = await import('react'); + + return { + default: () => React.createElement('div', { role: 'status' }, 'RPC good'), + }; +}); + +vi.mock('@/components/common/CreatorCard', async () => { + const React = await import('react'); + + return { + default: ({ creator }: { creator: { title: string } }) => + React.createElement( + 'article', + { 'aria-label': `Creator ${creator.title}` }, + creator.title + ), + }; +}); + +vi.mock('@/components/common/FeaturedCreatorAudienceChip', async () => { + const React = await import('react'); + + return { + FeaturedCreatorAudienceChip: () => + React.createElement('div', { 'data-testid': 'mock-audience-chip' }), + }; +}); + +vi.mock('framer-motion', async () => { + const React = await import('react'); + type MotionDivProps = ComponentProps<'div'> & { + layout?: boolean; + transition?: unknown; + }; + + return { + AnimatePresence: ({ children }: { children: ReactNode }) => + React.createElement(React.Fragment, null, children), + LayoutGroup: ({ children }: { children: ReactNode }) => + React.createElement(React.Fragment, null, children), + motion: { + div: ({ children, ...props }: MotionDivProps) => { + const { layout, transition, ...divProps } = props; + void layout; + void transition; + + return React.createElement('div', divProps, children); + }, + h1: ({ children, ...props }: ComponentProps<'h1'>) => + React.createElement('h1', props, children), + button: ({ children, ...props }: ComponentProps<'button'>) => + React.createElement('button', props, children), + }, + }; +}); + +const mockGetCourses = vi.mocked(courseService.getCourses); +const mockShowToast = vi.mocked(showToast); + +const featuredCreatorOnly: Course[] = [ + { + id: '1', + title: 'Alex Rivers', + description: 'Digital Artist & Illustrator', + price: 0.05, + priceStroops: 500_000, + creatorShareSupply: 120, + instructorId: '1', + category: 'Art', + level: 'BEGINNER', + isVerified: true, + }, +]; + +const mockMatchMedia = () => { + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); +}; + +// Newer Node versions expose a global WebStorage `localStorage` that +// shadows jsdom's and has no working methods; install a spec-compliant +// in-memory stub so this suite behaves identically on every Node version. +const installStorageStub = (property: 'localStorage' | 'sessionStorage') => { + const store = new Map(); + Object.defineProperty(window, property, { + configurable: true, + writable: true, + value: { + getItem: (key: string) => store.get(String(key)) ?? null, + setItem: (key: string, value: string) => { + store.set(String(key), String(value)); + }, + removeItem: (key: string) => { + store.delete(String(key)); + }, + clear: () => store.clear(), + key: (index: number) => Array.from(store.keys())[index] ?? null, + get length() { + return store.size; + }, + }, + }); +}; + +const renderLandingPage = () => + render( + + + + + + ); + +describe('LandingPage sell flow end-to-end (#644)', () => { + beforeEach(() => { + mockMatchMedia(); + installStorageStub('localStorage'); + installStorageStub('sessionStorage'); + mockGetCourses.mockReset(); + vi.clearAllMocks(); + mockGetCourses.mockResolvedValue(featuredCreatorOnly); + }); + + afterEach(() => { + cleanup(); + }); + + it('completes the sell flow from quantity input to success toast and updated holdings', async () => { + renderLandingPage(); + + // Wallet connected with 3 keys held for the featured creator + await screen.findByText('3 keys · 0.05 XLM'); + + // Open the trade panel on the sell side + const [sellButton] = screen.getAllByRole('button', { name: 'Sell' }); + fireEvent.click(sellButton); + + // Enter quantity 2 + const amountInput = await screen.findByTestId('trade-dialog-amount'); + fireEvent.change(amountInput, { target: { value: '2' } }); + + // Submit and wait through the simulated on-chain confirmation + fireEvent.click(screen.getByTestId('trade-dialog-confirm')); + + await waitFor( + () => + expect(mockShowToast.transactionSuccess).toHaveBeenCalledWith( + 'Trade confirmed', + 'Sold 2 keys from Alex Rivers' + ), + { timeout: 5000 } + ); + + // Holdings cache reflects 1 remaining key + await waitFor( + () => expect(screen.getByText('1 keys · 0.05 XLM')).toBeInTheDocument(), + { timeout: 5000 } + ); + expect(screen.queryByText('3 keys · 0.05 XLM')).toBeNull(); + + // No error state at any stage of the flow + expect(mockShowToast.error).not.toHaveBeenCalled(); + }); + + it('reports the submitted quantity while the transaction is pending', async () => { + renderLandingPage(); + await screen.findByText('3 keys · 0.05 XLM'); + + const [sellButton] = screen.getAllByRole('button', { name: 'Sell' }); + fireEvent.click(sellButton); + fireEvent.change(await screen.findByTestId('trade-dialog-amount'), { + target: { value: '2' }, + }); + fireEvent.click(screen.getByTestId('trade-dialog-confirm')); + + expect(mockShowToast.loading).toHaveBeenCalledWith( + 'Submitting sell for 2 keys...' + ); + }); + + it('uses the singular key wording when selling exactly one', async () => { + renderLandingPage(); + await screen.findByText('3 keys · 0.05 XLM'); + + const [sellButton] = screen.getAllByRole('button', { name: 'Sell' }); + fireEvent.click(sellButton); + fireEvent.change(await screen.findByTestId('trade-dialog-amount'), { + target: { value: '1' }, + }); + fireEvent.click(screen.getByTestId('trade-dialog-confirm')); + + await waitFor( + () => + expect(mockShowToast.transactionSuccess).toHaveBeenCalledWith( + 'Trade confirmed', + 'Sold 1 key from Alex Rivers' + ), + { timeout: 5000 } + ); + }); +}); diff --git a/src/utils/__tests__/unhandledRejectionLogger.test.ts b/src/utils/__tests__/unhandledRejectionLogger.test.ts new file mode 100644 index 00000000..4250006c --- /dev/null +++ b/src/utils/__tests__/unhandledRejectionLogger.test.ts @@ -0,0 +1,117 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + __resetUnhandledRejectionLogger, + registerUnhandledRejectionLogger, +} from '@/utils/unhandledRejectionLogger'; + +function dispatchRejection(reason: unknown) { + const preventDefault = vi.fn(); + const event = { + reason, + promise: Promise.resolve(), + preventDefault, + } as unknown as PromiseRejectionEvent; + + window.onunhandledrejection?.call(window, event); + return { preventDefault }; +} + +describe('unhandledRejectionLogger (#647)', () => { + let debugSpy: ReturnType; + + beforeEach(() => { + __resetUnhandledRejectionLogger(); + debugSpy = vi.spyOn(console, 'debug').mockImplementation(() => {}); + }); + + afterEach(() => { + debugSpy.mockRestore(); + __resetUnhandledRejectionLogger(); + }); + + it('registers a window.onunhandledrejection handler', () => { + expect(window.onunhandledrejection).toBeNull(); + registerUnhandledRejectionLogger({ isTestEnv: false }); + expect(typeof window.onunhandledrejection).toBe('function'); + }); + + it('registers only once — later calls do not replace the handler', () => { + registerUnhandledRejectionLogger({ isTestEnv: false }); + const firstHandler = window.onunhandledrejection; + + registerUnhandledRejectionLogger({ isTestEnv: false }); + expect(window.onunhandledrejection).toBe(firstHandler); + + dispatchRejection(new Error('boom')); + expect(debugSpy).toHaveBeenCalledTimes(1); + }); + + it('emits a structured log with reason, promise_origin and rejected_at', () => { + registerUnhandledRejectionLogger({ isTestEnv: false }); + + dispatchRejection(new Error('payment fetch failed')); + + expect(debugSpy).toHaveBeenCalledWith( + '[unhandled-rejection]', + expect.objectContaining({ + reason: 'Error: payment fetch failed', + promise_origin: expect.any(String), + rejected_at: expect.stringMatching( + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/ + ), + }) + ); + }); + + it('derives promise_origin from the error stack when available', () => { + registerUnhandledRejectionLogger({ isTestEnv: false }); + + dispatchRejection(new Error('with stack')); + + const [, log] = debugSpy.mock.calls[0]; + expect((log as { promise_origin: string }).promise_origin).not.toBe( + 'unknown' + ); + }); + + it('handles non-Error rejection reasons', () => { + registerUnhandledRejectionLogger({ isTestEnv: false }); + + dispatchRejection('plain string reason'); + + expect(debugSpy).toHaveBeenCalledWith( + '[unhandled-rejection]', + expect.objectContaining({ + reason: 'plain string reason', + promise_origin: 'unknown', + }) + ); + }); + + it('does not suppress default browser behaviour', () => { + registerUnhandledRejectionLogger({ isTestEnv: false }); + + const { preventDefault } = dispatchRejection(new Error('boom')); + + expect(preventDefault).not.toHaveBeenCalled(); + }); + + it('emits nothing in the test environment (default detection)', () => { + // Vitest sets import.meta.env.MODE to 'test', so the default + // registration path must stay silent. + registerUnhandledRejectionLogger(); + + dispatchRejection(new Error('should not be logged')); + + expect(debugSpy).not.toHaveBeenCalled(); + }); + + it('emits one log per rejection', () => { + registerUnhandledRejectionLogger({ isTestEnv: false }); + + dispatchRejection(new Error('first')); + dispatchRejection(new Error('second')); + + expect(debugSpy).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/utils/unhandledRejectionLogger.ts b/src/utils/unhandledRejectionLogger.ts new file mode 100644 index 00000000..2962baf6 --- /dev/null +++ b/src/utils/unhandledRejectionLogger.ts @@ -0,0 +1,88 @@ +/** + * Structured logging for unhandled promise rejections. + * + * Fire-and-forget async calls that live outside React (event handlers, + * module-level warmup, detached timers) reject silently in production — + * the React error boundary never sees them because it only catches errors + * thrown during rendering, which is also why this handler cannot duplicate + * boundary logs: the two failure classes are disjoint. + * + * Registered once at app initialisation (module scope in `main.tsx`), not + * inside a component, so re-renders can never re-register it. + */ + +export interface UnhandledRejectionLog { + reason: string; + promise_origin: string; + rejected_at: string; +} + +interface RegisterOptions { + /** + * Suppresses log emission when true. Defaults to detecting Vitest via + * `import.meta.env.MODE === 'test'`; injectable so the logger itself + * can be tested. + */ + isTestEnv?: boolean; +} + +let registered = false; + +function describeReason(reason: unknown): string { + if (reason instanceof Error) { + return `${reason.name}: ${reason.message}`; + } + if (typeof reason === 'string') return reason; + try { + return JSON.stringify(reason); + } catch { + return String(reason); + } +} + +function describeOrigin(reason: unknown): string { + if (reason instanceof Error && reason.stack) { + // First stack frame below the error message — the rejection site + const frame = reason.stack + .split('\n') + .slice(1) + .map(line => line.trim()) + .find(line => line.length > 0); + if (frame) return frame; + } + return 'unknown'; +} + +/** + * Registers the `window.onunhandledrejection` handler. Safe to call more + * than once — only the first call installs the handler. + * + * The handler never calls `preventDefault()`, so the browser's default + * unhandled-rejection reporting is preserved. + */ +export function registerUnhandledRejectionLogger( + options: RegisterOptions = {} +): void { + const { isTestEnv = import.meta.env.MODE === 'test' } = options; + + if (registered) return; + registered = true; + + window.onunhandledrejection = event => { + if (isTestEnv) return; + + const log: UnhandledRejectionLog = { + reason: describeReason(event.reason), + promise_origin: describeOrigin(event.reason), + rejected_at: new Date().toISOString(), + }; + + console.debug('[unhandled-rejection]', log); + }; +} + +/** Test hook: unregister so each test starts from a clean slate. */ +export function __resetUnhandledRejectionLogger(): void { + registered = false; + window.onunhandledrejection = null; +}