From 9f6743f7cb0ca792200c22be62e07c5aab117177 Mon Sep 17 00:00:00 2001 From: ajithakdev Date: Sun, 6 Sep 2026 20:38:00 +0530 Subject: [PATCH] feat(resilience): add glassmorphic ErrorBoundary and custom 404 page (#56, #65) --- src/App.tsx | 14 +- src/components/ErrorBoundary.test.tsx | 121 +++++++++ src/components/ErrorBoundary.tsx | 344 +++++++++++++++++++++++ src/pages/NotFound.test.tsx | 97 +++++++ src/pages/NotFound.tsx | 377 ++++++++++++++++++++++++++ src/pages/ToolPage.tsx | 17 +- 6 files changed, 960 insertions(+), 10 deletions(-) create mode 100644 src/components/ErrorBoundary.test.tsx create mode 100644 src/components/ErrorBoundary.tsx create mode 100644 src/pages/NotFound.test.tsx create mode 100644 src/pages/NotFound.tsx diff --git a/src/App.tsx b/src/App.tsx index e06e779..24da393 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -8,6 +8,8 @@ import { ToastProvider } from './components/ui/Toast'; import { useTheme } from './hooks/useTheme'; import { useToolStats } from './hooks/useToolStats'; import { Search, Download, ExternalLink, Sparkles } from 'lucide-react'; +import { ErrorBoundary } from './components/ErrorBoundary'; +import { NotFound } from './pages/NotFound'; function InstallPwaButton() { // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -209,11 +211,13 @@ export default function App() { return ( setPaletteOpen(true)}> - - } /> - } /> - } /> - + + + } /> + } /> + } /> + + setPaletteOpen(false)} /> diff --git a/src/components/ErrorBoundary.test.tsx b/src/components/ErrorBoundary.test.tsx new file mode 100644 index 0000000..8572163 --- /dev/null +++ b/src/components/ErrorBoundary.test.tsx @@ -0,0 +1,121 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { act, useState } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { MemoryRouter } from 'react-router-dom'; +import { ErrorBoundary } from './ErrorBoundary'; + +// Configure act environment for React 19 +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +function ProblemChild({ shouldThrow }: { shouldThrow: boolean }) { + if (shouldThrow) { + throw new Error('Simulated tool rendering crash'); + } + return
Normal Tool Content
; +} + +describe('ErrorBoundary Component', () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + act(() => { + root.unmount(); + }); + container.remove(); + vi.restoreAllMocks(); + }); + + it('renders children normally when no error occurs', () => { + act(() => { + root.render( + + + + + + ); + }); + + expect(container.textContent).toContain('Normal Tool Content'); + expect(container.querySelector('[data-testid="child-content"]')).not.toBeNull(); + expect(container.querySelector('[data-testid="error-boundary-card"]')).toBeNull(); + }); + + it('catches render errors and displays the glassmorphic error fallback UI', () => { + act(() => { + root.render( + + + + + + ); + }); + + expect(container.querySelector('[data-testid="error-boundary-card"]')).not.toBeNull(); + expect(container.textContent).toContain('Timestamp Converter encountered an error'); + expect(container.textContent).toContain('Simulated tool rendering crash'); + expect(container.textContent).toContain('100% Client-Side Privacy'); + }); + + it('renders custom fallback if provided', () => { + act(() => { + root.render( + + ( +
+ Custom: {error.message} + +
+ )} + > + +
+
+ ); + }); + + expect(container.textContent).toContain('Custom: Simulated tool rendering crash'); + expect(container.textContent).toContain('Custom Reset'); + }); + + it('allows recovery via Try Again reset button', () => { + function StatefulWrapper() { + const [hasError, setHasError] = useState(true); + return ( + + setHasError(false)}> + + + + ); + } + + act(() => { + root.render(); + }); + + expect(container.textContent).toContain('Test Tool encountered an error'); + + const tryAgainBtn = Array.from(container.querySelectorAll('button')).find((b) => + b.textContent?.includes('Try Again') + ); + expect(tryAgainBtn).toBeDefined(); + + act(() => { + tryAgainBtn?.click(); + }); + + expect(container.textContent).toContain('Normal Tool Content'); + }); +}); diff --git a/src/components/ErrorBoundary.tsx b/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000..7d3b91c --- /dev/null +++ b/src/components/ErrorBoundary.tsx @@ -0,0 +1,344 @@ +import { Component, type ErrorInfo, type ReactNode } from 'react'; +import { AlertTriangle, RotateCcw, ArrowLeft, Copy, Check, ShieldCheck } from 'lucide-react'; +import { Link } from 'react-router-dom'; + +interface ErrorBoundaryProps { + children: ReactNode; + toolTitle?: string; + fallback?: (error: Error, reset: () => void) => ReactNode; + onReset?: () => void; +} + +interface ErrorBoundaryState { + hasError: boolean; + error: Error | null; + errorInfo: ErrorInfo | null; + copied: boolean; + showDetails: boolean; +} + +export class ErrorBoundary extends Component { + constructor(props: ErrorBoundaryProps) { + super(props); + this.state = { + hasError: false, + error: null, + errorInfo: null, + copied: false, + showDetails: false, + }; + } + + static getDerivedStateFromError(error: Error): Partial { + return { hasError: true, error }; + } + + componentDidCatch(error: Error, errorInfo: ErrorInfo) { + this.setState({ errorInfo }); + // Local debugging in dev console only — STRICTLY NO remote telemetry + if (process.env.NODE_ENV === 'development') { + console.error('[Toolglass ErrorBoundary Caught Error]:', error, errorInfo); + } + } + + resetError = () => { + this.props.onReset?.(); + this.setState({ + hasError: false, + error: null, + errorInfo: null, + copied: false, + showDetails: false, + }); + }; + + handleCopyDebugInfo = async () => { + const { error, errorInfo } = this.state; + const { toolTitle } = this.props; + + const debugReport = [ + `### Toolglass Error Report`, + `- **Tool**: ${toolTitle || 'Application Shell'}`, + `- **Timestamp**: ${new Date().toISOString()}`, + `- **Error Message**: ${error?.message || 'Unknown error'}`, + `- **Error Name**: ${error?.name || 'Error'}`, + ``, + `#### Component Stack`, + '```text', + errorInfo?.componentStack?.trim() || 'No component stack available', + '```', + ``, + `*Note: Generated locally in Toolglass. 100% private, no remote tracking.*`, + ].join('\n'); + + try { + await navigator.clipboard.writeText(debugReport); + this.setState({ copied: true }); + setTimeout(() => this.setState({ copied: false }), 2000); + } catch { + // Clipboard write fallback + } + }; + + render() { + const { hasError, error, errorInfo, copied, showDetails } = this.state; + const { children, toolTitle, fallback } = this.props; + + if (!hasError) { + return children; + } + + if (fallback && error) { + return fallback(error, this.resetError); + } + + return ( +
+ {/* Subtle glowing ambient backdrop */} +
+ + {/* Error icon badge */} +
+ +
+ + {/* Title */} +

+ {toolTitle ? `${toolTitle} encountered an error` : 'Something went wrong'} +

+ + {/* Subtitle / friendly message */} +

+ An unexpected issue occurred while rendering this tool. The rest of Toolglass remains fully functional. +

+ + {/* Error message pill */} + {error && ( +
+ Error: + {error.message || 'Unknown runtime exception'} +
+ )} + + {/* Privacy assurance callout */} +
+ + 100% Client-Side Privacy: No error logs or data were sent over the network. +
+ + {/* Action buttons */} +
+ + + + + Return to Tools + + + +
+ + {/* Technical stack toggle */} + {errorInfo && ( +
+ + + {showDetails && ( +
+                {errorInfo.componentStack?.trim() || 'No component stack recorded'}
+              
+ )} +
+ )} +
+ ); + } +} diff --git a/src/pages/NotFound.test.tsx b/src/pages/NotFound.test.tsx new file mode 100644 index 0000000..01cb5aa --- /dev/null +++ b/src/pages/NotFound.test.tsx @@ -0,0 +1,97 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { MemoryRouter } from 'react-router-dom'; +import { NotFound } from './NotFound'; + +// Configure act environment for React 19 +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +describe('NotFound Page Component', () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => { + root.unmount(); + }); + container.remove(); + vi.restoreAllMocks(); + }); + + it('renders general 404 page for unknown paths', () => { + act(() => { + root.render( + + + + ); + }); + + expect(container.querySelector('[data-testid="not-found-page"]')).not.toBeNull(); + expect(container.textContent).toContain('404'); + expect(container.textContent).toContain('Page Not Found'); + expect(container.textContent).toContain('/random-unknown-path'); + expect(container.textContent).toContain('Explore All Tools'); + }); + + it('renders tool-specific 404 when isToolNotFound is true', () => { + act(() => { + root.render( + + + + ); + }); + + expect(container.textContent).toContain('Tool Not Found:'); + expect(container.textContent).toContain('invalid-tool-name'); + }); + + it('suggests related tools when slug matches partially', () => { + act(() => { + root.render( + + + + ); + }); + + expect(container.textContent).toContain('Did you mean one of these tools?'); + expect(container.textContent).toContain('Timestamp'); + }); + + it('filters tools dynamically when typing in search input', () => { + act(() => { + root.render( + + + + ); + }); + + const searchInput = container.querySelector('input[type="text"]') as HTMLInputElement; + expect(searchInput).not.toBeNull(); + + act(() => { + const nativeSetter = Object.getOwnPropertyDescriptor( + window.HTMLInputElement.prototype, + 'value' + )?.set; + nativeSetter?.call(searchInput, 'base64'); + const inputEvent = new Event('input', { bubbles: true }); + searchInput.dispatchEvent(inputEvent); + const changeEvent = new Event('change', { bubbles: true }); + searchInput.dispatchEvent(changeEvent); + }); + + expect(container.textContent).toContain('Search Results for "base64"'); + }); +}); diff --git a/src/pages/NotFound.tsx b/src/pages/NotFound.tsx new file mode 100644 index 0000000..681fd09 --- /dev/null +++ b/src/pages/NotFound.tsx @@ -0,0 +1,377 @@ +import { useState, useMemo } from 'react'; +import { Link, useLocation } from 'react-router-dom'; +import { Sparkles, ArrowLeft, Search, Compass, AlertCircle } from 'lucide-react'; +import { tools, type ToolMeta } from '../tools/registry'; +import { ToolCard } from '../components/ui/ToolCard'; + +interface NotFoundProps { + attemptedSlug?: string; + isToolNotFound?: boolean; +} + +// Lightweight similarity score for fuzzy suggestions +function scoreToolMatch(query: string, tool: ToolMeta): number { + const q = query.toLowerCase().trim(); + if (!q) return 0; + + const slug = tool.slug.toLowerCase(); + const title = tool.title.toLowerCase(); + const shortDesc = tool.short.toLowerCase(); + + // Exact matches or prefix matches + if (slug === q) return 100; + if (slug.startsWith(q) || q.startsWith(slug)) return 80; + if (title.includes(q)) return 60; + if (slug.includes(q) || q.includes(slug)) return 50; + if (shortDesc.includes(q)) return 40; + + // Character overlap score + const qChars = new Set(q.split('')); + let matchCount = 0; + for (const c of qChars) { + if (slug.includes(c)) matchCount++; + } + return (matchCount / Math.max(q.length, slug.length)) * 30; +} + +export function NotFound({ attemptedSlug, isToolNotFound }: NotFoundProps) { + const location = useLocation(); + const [searchQuery, setSearchQuery] = useState(''); + + // Determine attempted route or slug + const cleanPath = location.pathname || ''; + const detectedSlug = attemptedSlug || (cleanPath.startsWith('/tools/') ? cleanPath.replace('/tools/', '') : ''); + const isTool = isToolNotFound || Boolean(detectedSlug); + + // Compute suggestions based on attempted slug or fallback to popular tools + const suggestedTools = useMemo(() => { + if (detectedSlug) { + const scored = tools + .map((t) => ({ tool: t, score: scoreToolMatch(detectedSlug, t) })) + .sort((a, b) => b.score - a.score); + + const matches = scored.filter((item) => item.score > 20).map((item) => item.tool); + if (matches.length > 0) { + return matches.slice(0, 3); + } + } + + // Default top utilities when no close match is found + const defaultSlugs = ['timestamp', 'jwt', 'password']; + return tools.filter((t) => defaultSlugs.includes(t.slug)); + }, [detectedSlug]); + + // Live filter if user types in the inline search box + const searchResults = useMemo(() => { + if (!searchQuery.trim()) return []; + const q = searchQuery.toLowerCase().trim(); + return tools.filter( + (t) => + t.title.toLowerCase().includes(q) || + t.slug.toLowerCase().includes(q) || + t.short.toLowerCase().includes(q) + ); + }, [searchQuery]); + + return ( +
+ {/* 404 Hero Card */} +
+ {/* Glowing atmospheric gradient */} +
+ + {/* 404 Visual Badge */} +
+ + 404 · ERROR +
+ + {/* Heading */} +

+ {isTool ? ( + <> + Tool Not Found:{' '} + + {detectedSlug || 'Unknown'} + + + ) : ( + 'Page Not Found' + )} +

+ + {/* Message */} +

+ {isTool + ? `The requested tool "${detectedSlug}" does not exist or may have been renamed.` + : `The path "${cleanPath}" does not exist in Toolglass.`} +

+ + {/* Inline Search Bar */} +
+
+ + setSearchQuery(e.target.value)} + aria-label="Search tools directly" + style={{ + width: '100%', + background: 'transparent', + border: 'none', + outline: 'none', + fontSize: 14, + color: 'var(--ink)', + fontFamily: 'var(--font-sans)', + }} + /> + {searchQuery && ( + + )} +
+
+ + {/* Actions */} +
+ + + Explore All Tools + +
+
+ + {/* Search Results (if user is actively typing) */} + {searchQuery.trim() && ( +
+

+ + Search Results for "{searchQuery}" ({searchResults.length}) +

+ + {searchResults.length > 0 ? ( +
+ {searchResults.map((tool) => ( + + ))} +
+ ) : ( +
+ + No tools matching "{searchQuery}". Try browsing all tools on the home page. +
+ )} +
+ )} + + {/* Suggested Tools Section (Shown when not actively searching) */} + {!searchQuery.trim() && suggestedTools.length > 0 && ( +
+
+

+ + {detectedSlug ? 'Did you mean one of these tools?' : 'Popular Utilities'} +

+ + View all 17 tools → + +
+ +
+ {suggestedTools.map((tool) => ( + + ))} +
+
+ )} +
+ ); +} +export default NotFound; diff --git a/src/pages/ToolPage.tsx b/src/pages/ToolPage.tsx index c1099df..ee4c907 100644 --- a/src/pages/ToolPage.tsx +++ b/src/pages/ToolPage.tsx @@ -1,8 +1,10 @@ import { AnimatePresence, motion } from 'framer-motion'; import { Suspense, useEffect, useState } from 'react'; -import { Navigate, useParams } from 'react-router-dom'; +import { useParams } from 'react-router-dom'; import { toolBySlug } from '../tools/registry'; import { useRecentTools } from '../hooks/useRecentTools'; +import { ErrorBoundary } from '../components/ErrorBoundary'; +import { NotFound } from './NotFound'; /* ── Cycling phrases shown while the tool chunk loads ────────────── */ const PHRASES = [ @@ -137,11 +139,16 @@ export function ToolPage() { } }, [tool, addRecent]); - if (!tool) return ; + if (!tool) { + return ; + } + const { Component } = tool; return ( - }> - - + + }> + + + ); }