diff --git a/app/api/screenshot/route.test.ts b/app/api/screenshot/route.test.ts new file mode 100644 index 0000000..7100cef --- /dev/null +++ b/app/api/screenshot/route.test.ts @@ -0,0 +1,314 @@ +/** + * Tests for the screenshot API route + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { POST } from './route'; + +// Mock playwright +jest.mock('playwright', () => ({ + chromium: { + connect: jest.fn() + }, + firefox: { + connect: jest.fn() + }, + webkit: { + connect: jest.fn() + }, + devices: {} +})); + +const mockBrowser = { + newPage: jest.fn(), + close: jest.fn() +}; + +const mockPage = { + setViewportSize: jest.fn(), + goto: jest.fn(), + screenshot: jest.fn(), + close: jest.fn() +}; + +describe('Screenshot API Route', () => { + let consoleErrorSpy: jest.SpyInstance; + let consoleLogSpy: jest.SpyInstance; + + beforeEach(() => { + jest.clearAllMocks(); + consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(); + + // Setup default mock implementations + const { chromium, firefox, webkit } = require('playwright'); + chromium.connect.mockResolvedValue(mockBrowser); + firefox.connect.mockResolvedValue(mockBrowser); + webkit.connect.mockResolvedValue(mockBrowser); + + mockBrowser.newPage.mockResolvedValue(mockPage); + mockPage.screenshot.mockResolvedValue(Buffer.from('fake-screenshot-data')); + }); + + afterEach(() => { + consoleErrorSpy.mockRestore(); + consoleLogSpy.mockRestore(); + }); + + describe('Error Handling', () => { + it('should return sanitized error when browser connection fails', async () => { + const { chromium } = require('playwright'); + chromium.connect.mockRejectedValueOnce(new Error('connect ECONNREFUSED ::1:8081')); + + const request = new NextRequest('http://localhost:3000/api/screenshot', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + url: 'https://example.com', + browser: 'chromium' + }) + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(503); + expect(data).toEqual({ + error: 'Browser connection failed', + details: 'Unable to connect to the browser service. Please try again later.', + code: 'CONNECTION_FAILED' + }); + + // Verify console logging is minimal + expect(consoleLogSpy).toHaveBeenCalledWith('Connecting to Playwright server for browser: chromium'); + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Browser connection failed:', + 'chromium', + 'connect ECONNREFUSED ::1:8081' + ); + }); + + it('should not expose environment variables in error messages', async () => { + const originalEnv = process.env.PLAYWRIGHT_SERVER_ENDPOINT; + process.env.PLAYWRIGHT_SERVER_ENDPOINT = 'http://secret-server:8081'; + + const { chromium } = require('playwright'); + chromium.connect.mockRejectedValueOnce(new Error('Connection refused')); + + const request = new NextRequest('http://localhost:3000/api/screenshot', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + url: 'https://example.com', + browser: 'chromium' + }) + }); + + const response = await POST(request); + const data = await response.json(); + + // Ensure the secret endpoint is not in the response + expect(JSON.stringify(data)).not.toContain('secret-server'); + expect(data.details).toBe('Unable to connect to the browser service. Please try again later.'); + + // Restore env + process.env.PLAYWRIGHT_SERVER_ENDPOINT = originalEnv; + }); + + it('should handle different browser types in error logging', async () => { + const { firefox } = require('playwright'); + firefox.connect.mockRejectedValueOnce(new Error('Firefox connection error')); + + const request = new NextRequest('http://localhost:3000/api/screenshot', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + url: 'https://example.com', + browser: 'firefox' + }) + }); + + const response = await POST(request); + await response.json(); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Browser connection failed:', + 'firefox', + 'Firefox connection error' + ); + }); + + it('should handle non-Error objects in catch block', async () => { + const { webkit } = require('playwright'); + webkit.connect.mockRejectedValueOnce('String error'); + + const request = new NextRequest('http://localhost:3000/api/screenshot', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + url: 'https://example.com', + browser: 'webkit' + }) + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(503); + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Browser connection failed:', + 'webkit', + 'Unknown error' + ); + }); + + it('should return 400 for missing URL', async () => { + const request = new NextRequest('http://localhost:3000/api/screenshot', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + browser: 'chromium' + }) + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.error).toBe('URL is required'); + }); + + it('should return 400 for invalid URL', async () => { + const request = new NextRequest('http://localhost:3000/api/screenshot', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + url: 'not-a-valid-url', + browser: 'chromium' + }) + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.error).toBe('Invalid URL format'); + }); + + it('should handle timeout errors', async () => { + mockPage.goto.mockRejectedValueOnce(new Error('timeout 30000ms exceeded')); + + const request = new NextRequest('http://localhost:3000/api/screenshot', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + url: 'https://slow-site.com', + browser: 'chromium' + }) + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(504); + expect(data.error).toBe('Screenshot timeout'); + expect(data.details).toBe('The page took too long to load. Please try again with a faster-loading page.'); + expect(data.code).toBe('TIMEOUT'); + }); + + it('should clean up browser on error', async () => { + mockPage.screenshot.mockRejectedValueOnce(new Error('Screenshot failed')); + + const request = new NextRequest('http://localhost:3000/api/screenshot', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + url: 'https://example.com', + browser: 'chromium' + }) + }); + + await POST(request); + + expect(mockBrowser.close).toHaveBeenCalled(); + }); + }); + + describe('Successful Screenshots', () => { + it('should successfully take a screenshot', async () => { + const request = new NextRequest('http://localhost:3000/api/screenshot', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + url: 'https://example.com', + browser: 'chromium', + width: 1920, + height: 1080, + quality: 80 + }) + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.success).toBe(true); + expect(data.screenshot).toMatch(/^data:image\/jpeg;base64,/); + expect(data.metadata).toMatchObject({ + url: 'https://example.com', + resolution: '1920x1080', + browser: 'chromium', + fullPage: false, + quality: 80 + }); + + expect(mockPage.setViewportSize).toHaveBeenCalledWith({ width: 1920, height: 1080 }); + expect(mockPage.goto).toHaveBeenCalledWith('https://example.com', { + waitUntil: 'domcontentloaded', + timeout: 30000 + }); + expect(mockPage.screenshot).toHaveBeenCalledWith({ + type: 'jpeg', + quality: 80, + fullPage: false + }); + }); + + it('should log minimal information on success', async () => { + const request = new NextRequest('http://localhost:3000/api/screenshot', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + url: 'https://example.com', + browser: 'firefox' + }) + }); + + await POST(request); + + // Should only log the connection attempt, not the full endpoint + expect(consoleLogSpy).toHaveBeenCalledWith('Connecting to Playwright server for browser: firefox'); + expect(consoleLogSpy).toHaveBeenCalledTimes(1); + }); + }); +}); \ No newline at end of file diff --git a/app/api/screenshot/route.ts b/app/api/screenshot/route.ts index 8c66b7b..4975d18 100644 --- a/app/api/screenshot/route.ts +++ b/app/api/screenshot/route.ts @@ -73,7 +73,7 @@ export async function POST(request: NextRequest) { chromium; try { - console.log(`Connecting to Playwright server at ${playwrightEndpoint} for browser: ${browserType}`); + console.log(`Connecting to Playwright server for browser: ${browserType}`); const browserSpecificOptions = { channel: browserType === 'msedge' ? 'msedge' : browserType === 'chrome' ? 'chrome' : @@ -84,15 +84,13 @@ export async function POST(request: NextRequest) { ...browserSpecificOptions }); } catch (connectError) { - console.error('Failed to connect to Playwright server:', connectError); + // Log only essential information for debugging + console.error('Browser connection failed:', browserType, connectError instanceof Error ? connectError.message : 'Unknown error'); return NextResponse.json( { error: 'Browser connection failed', - details: `Unable to connect to the Playwright server at ${playwrightEndpoint}. The server is returning: "${connectError instanceof Error ? connectError.message : String(connectError)}". Please ensure a Playwright WebSocket server is running.`, - code: 'CONNECTION_FAILED', - endpoint: playwrightEndpoint, - browserType: browserType, - hint: 'The server should be started with: npx playwright run-server --port 8081' + details: 'Unable to connect to the browser service. Please try again later.', + code: 'CONNECTION_FAILED' }, { status: 503 } ); diff --git a/app/screenshotter/page.test.tsx b/app/screenshotter/page.test.tsx new file mode 100644 index 0000000..9f837eb --- /dev/null +++ b/app/screenshotter/page.test.tsx @@ -0,0 +1,265 @@ +/** + * Tests for the Screenshotter page + * @jest-environment jsdom + */ + +import React from 'react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import ScreenshotterPage from './page'; + +// Mock fetch +global.fetch = jest.fn(); + +describe('ScreenshotterPage', () => { + beforeEach(() => { + jest.clearAllMocks(); + // Mock successful devices fetch + (fetch as jest.Mock).mockImplementation((url) => { + if (url === '/api/devices') { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ + devices: [ + { value: 'none', label: 'No device emulation' }, + { value: 'iPhone 14', label: 'iPhone 14' }, + { value: 'iPad', label: 'iPad' }, + ] + }) + }); + } + return Promise.reject(new Error('Unknown URL')); + }); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('Error Display', () => { + it('should display simple error messages with red styling', async () => { + render(); + + // Mock failed screenshot request + (fetch as jest.Mock).mockImplementationOnce((url) => { + if (url === '/api/screenshot') { + return Promise.resolve({ + ok: false, + json: () => Promise.resolve({ + error: 'Failed to take screenshot' + }) + }); + } + return Promise.resolve({ ok: true, json: () => Promise.resolve({ devices: [] }) }); + }); + + const urlInput = screen.getByPlaceholderText('https://digitalocean.com'); + const button = screen.getByText('Take Screenshot'); + + fireEvent.change(urlInput, { target: { value: 'https://example.com' } }); + fireEvent.click(button); + + await waitFor(() => { + const errorElement = screen.getByText('Failed to take screenshot'); + expect(errorElement).toBeInTheDocument(); + // Check that it's within the red error container + const container = errorElement.closest('.bg-red-50'); + expect(container).toBeInTheDocument(); + expect(container).toHaveClass('mt-2', 'bg-red-50', 'border', 'border-red-200', 'rounded-lg', 'p-3'); + }); + }); + + it('should display detailed error messages with all fields', async () => { + render(); + + // Mock failed screenshot request with detailed error + (fetch as jest.Mock).mockImplementationOnce((url) => { + if (url === '/api/screenshot') { + return Promise.resolve({ + ok: false, + json: () => Promise.resolve({ + error: 'Browser connection failed', + details: 'Unable to connect to the browser service. Please try again later.', + code: 'CONNECTION_FAILED' + }) + }); + } + return Promise.resolve({ ok: true, json: () => Promise.resolve({ devices: [] }) }); + }); + + const urlInput = screen.getByPlaceholderText('https://digitalocean.com'); + const button = screen.getByText('Take Screenshot'); + + fireEvent.change(urlInput, { target: { value: 'https://example.com' } }); + fireEvent.click(button); + + await waitFor(() => { + expect(screen.getByText('Browser connection failed')).toBeInTheDocument(); + expect(screen.getByText(/Unable to connect to the browser service/)).toBeInTheDocument(); + expect(screen.getByText('CONNECTION_FAILED')).toBeInTheDocument(); + }); + }); + + it('should display multi-line errors with proper formatting', async () => { + render(); + + // Mock failed screenshot request with multi-line error + (fetch as jest.Mock).mockImplementationOnce((url) => { + if (url === '/api/screenshot') { + return Promise.resolve({ + ok: false, + json: () => Promise.resolve({ + error: 'Multiple errors occurred', + details: 'Error 1: Connection timeout\nError 2: Invalid credentials\nError 3: Server unavailable', + code: 'MULTIPLE_ERRORS' + }) + }); + } + return Promise.resolve({ ok: true, json: () => Promise.resolve({ devices: [] }) }); + }); + + const urlInput = screen.getByPlaceholderText('https://digitalocean.com'); + const button = screen.getByText('Take Screenshot'); + + fireEvent.change(urlInput, { target: { value: 'https://example.com' } }); + fireEvent.click(button); + + await waitFor(() => { + expect(screen.getByText('Multiple errors occurred')).toBeInTheDocument(); + // Check that the multi-line details are displayed + expect(screen.getByText(/Error 1: Connection timeout/)).toBeInTheDocument(); + }); + }); + + it('should handle errors without details or code fields', async () => { + render(); + + // Mock failed screenshot request with only error message + (fetch as jest.Mock).mockImplementationOnce((url) => { + if (url === '/api/screenshot') { + return Promise.resolve({ + ok: false, + json: () => Promise.resolve({ + error: 'Simple error message' + }) + }); + } + return Promise.resolve({ ok: true, json: () => Promise.resolve({ devices: [] }) }); + }); + + const urlInput = screen.getByPlaceholderText('https://digitalocean.com'); + const button = screen.getByText('Take Screenshot'); + + fireEvent.change(urlInput, { target: { value: 'https://example.com' } }); + fireEvent.click(button); + + await waitFor(() => { + const errorElement = screen.getByText('Simple error message'); + expect(errorElement).toBeInTheDocument(); + // Should not contain "Details:" or "Error Code:" when not provided + expect(screen.queryByText(/Details:/)).not.toBeInTheDocument(); + expect(screen.queryByText(/Error Code:/)).not.toBeInTheDocument(); + }); + }); + + it('should handle network errors gracefully', async () => { + render(); + + // Mock network error + (fetch as jest.Mock).mockImplementationOnce((url) => { + if (url === '/api/screenshot') { + return Promise.reject(new Error('Network error')); + } + return Promise.resolve({ ok: true, json: () => Promise.resolve({ devices: [] }) }); + }); + + const urlInput = screen.getByPlaceholderText('https://digitalocean.com'); + const button = screen.getByText('Take Screenshot'); + + fireEvent.change(urlInput, { target: { value: 'https://example.com' } }); + fireEvent.click(button); + + await waitFor(() => { + const errorElement = screen.getByText('Network error'); + expect(errorElement).toBeInTheDocument(); + // Check that it's within the red error container + const container = errorElement.closest('.bg-red-50'); + expect(container).toBeInTheDocument(); + }); + }); + + it('should clear error when user modifies the URL', async () => { + render(); + + // First, trigger an error + (fetch as jest.Mock).mockImplementationOnce((url) => { + if (url === '/api/screenshot') { + return Promise.resolve({ + ok: false, + json: () => Promise.resolve({ + error: 'Test error' + }) + }); + } + return Promise.resolve({ ok: true, json: () => Promise.resolve({ devices: [] }) }); + }); + + const urlInput = screen.getByPlaceholderText('https://digitalocean.com'); + const button = screen.getByText('Take Screenshot'); + + fireEvent.change(urlInput, { target: { value: 'https://example.com' } }); + fireEvent.click(button); + + await waitFor(() => { + expect(screen.getByText('Test error')).toBeInTheDocument(); + }); + + // Now change the URL + fireEvent.change(urlInput, { target: { value: 'https://newsite.com' } }); + + // Error should be cleared + expect(screen.queryByText('Test error')).not.toBeInTheDocument(); + }); + }); + + describe('Successful Screenshot', () => { + it('should display screenshot on success', async () => { + render(); + + const mockScreenshotData = 'data:image/jpeg;base64,/9j/4AAQSkZJRg...'; + + (fetch as jest.Mock).mockImplementationOnce((url) => { + if (url === '/api/screenshot') { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ + success: true, + screenshot: mockScreenshotData, + metadata: { + url: 'https://example.com', + timestamp: new Date().toISOString(), + resolution: '1920x1080', + browser: 'chromium', + fullPage: false, + quality: 80 + } + }) + }); + } + return Promise.resolve({ ok: true, json: () => Promise.resolve({ devices: [] }) }); + }); + + const urlInput = screen.getByPlaceholderText('https://digitalocean.com'); + const button = screen.getByText('Take Screenshot'); + + fireEvent.change(urlInput, { target: { value: 'https://example.com' } }); + fireEvent.click(button); + + await waitFor(() => { + const screenshotImg = screen.getByAltText('Screenshot'); + expect(screenshotImg).toBeInTheDocument(); + expect(screenshotImg).toHaveAttribute('src', mockScreenshotData); + }); + }); + }); +}); \ No newline at end of file diff --git a/app/screenshotter/page.tsx b/app/screenshotter/page.tsx index 1a407a2..854dc1f 100644 --- a/app/screenshotter/page.tsx +++ b/app/screenshotter/page.tsx @@ -43,7 +43,7 @@ export default function ScreenshotterPage() { const [fullPage, setFullPage] = useState(false); const [highQuality, setHighQuality] = useState(false); const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); + const [error, setError] = useState<{ error?: string; details?: string; code?: string } | null>(null); const [screenshot, setScreenshot] = useState(null); const [showLightbox, setShowLightbox] = useState(false); @@ -71,7 +71,7 @@ export default function ScreenshotterPage() { const handleScreenshot = async () => { if (!url) { - setError("Please enter a URL"); + setError({ error: "Please enter a URL" }); return; } @@ -79,7 +79,7 @@ export default function ScreenshotterPage() { try { new URL(url); } catch { - setError("Please enter a valid URL"); + setError({ error: "Please enter a valid URL" }); return; } @@ -109,15 +109,19 @@ export default function ScreenshotterPage() { if (!response.ok) { const data = await response.json(); - throw new Error(data.error || "Failed to take screenshot"); + throw data; } const data = await response.json(); setScreenshot(data.screenshot); } catch (err) { - setError( - err instanceof Error ? err.message : "An unexpected error occurred" - ); + if (err && typeof err === 'object' && 'error' in err) { + setError(err as { error?: string; details?: string; code?: string }); + } else if (err instanceof Error) { + setError({ error: err.message }); + } else { + setError({ error: "An unexpected error occurred" }); + } } finally { setLoading(false); } @@ -189,36 +193,20 @@ export default function ScreenshotterPage() { {error && ( -
- {(() => { - // Check if the error contains JSON - try { - // Try to parse as JSON first - const parsed = JSON.parse(error); - return ( -
-                          {JSON.stringify(parsed, null, 2)}
-                        
- ); - } catch { - // If not JSON, check if it looks like a code/technical error - if ( - error.includes("{") || - error.includes("Error:") || - error.length > 100 - ) { - return ( -
-                            {error}
-                          
- ); - } - // Otherwise, display as regular text - return ( -
{error}
- ); - } - })()} +
+
+
{error.error || "Failed to take screenshot"}
+ {error.details && ( +
+ Details: {error.details} +
+ )} + {error.code && ( +
+ Error Code: {error.code} +
+ )} +
)}
diff --git a/components/chat/ApiErrorDisplay.test.tsx b/components/chat/ApiErrorDisplay.test.tsx new file mode 100644 index 0000000..794f622 --- /dev/null +++ b/components/chat/ApiErrorDisplay.test.tsx @@ -0,0 +1,420 @@ +/** + * @jest-environment jsdom + */ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import ApiErrorDisplay from './ApiErrorDisplay'; + +describe('ApiErrorDisplay Component', () => { + describe('Basic error rendering', () => { + it('should render error with just a message', () => { + const error = { message: 'Something went wrong' }; + render(); + + expect(screen.getByText('API Error')).toBeInTheDocument(); + expect(screen.getByText('Something went wrong')).toBeInTheDocument(); + expect(screen.getByTestId('alert-triangle')).toBeInTheDocument(); + }); + + it('should render default message when no message provided', () => { + const error = {}; + render(); + + expect(screen.getByText('API Error')).toBeInTheDocument(); + expect(screen.getByText('An error occurred')).toBeInTheDocument(); + }); + + it('should render error with status code', () => { + const error = { + message: 'Bad Request', + statusCode: 400 + }; + render(); + + expect(screen.getByText('API Error (400)')).toBeInTheDocument(); + expect(screen.getByText('Bad Request')).toBeInTheDocument(); + }); + + it('should render error with status code but no message', () => { + const error = { statusCode: 500 }; + render(); + + expect(screen.getByText('API Error (500)')).toBeInTheDocument(); + expect(screen.getByText('An error occurred')).toBeInTheDocument(); + }); + }); + + describe('ResponseBody handling', () => { + it('should parse JSON responseBody and use message from it', () => { + const error = { + message: 'Original message', + responseBody: JSON.stringify({ + message: 'JSON message', + details: 'Additional details' + }) + }; + render(); + + // Should use message from JSON, not original message + expect(screen.getByText('JSON message')).toBeInTheDocument(); + expect(screen.queryByText('Original message')).not.toBeInTheDocument(); + + // Details should be available + expect(screen.getByText('Show details')).toBeInTheDocument(); + }); + + it('should handle JSON responseBody with only message', () => { + const error = { + responseBody: JSON.stringify({ + message: 'JSON only message' + }) + }; + render(); + + expect(screen.getByText('JSON only message')).toBeInTheDocument(); + expect(screen.queryByText('Show details')).not.toBeInTheDocument(); + }); + + it('should handle JSON responseBody with only details', () => { + const error = { + message: 'Original message', + responseBody: JSON.stringify({ + details: 'Some details without message' + }) + }; + render(); + + expect(screen.getByText('Original message')).toBeInTheDocument(); + expect(screen.getByText('Show details')).toBeInTheDocument(); + }); + + it('should handle non-JSON responseBody as details', () => { + const error = { + message: 'Error occurred', + responseBody: 'Plain text error response' + }; + render(); + + expect(screen.getByText('Error occurred')).toBeInTheDocument(); + expect(screen.getByText('Show details')).toBeInTheDocument(); + }); + + it('should handle malformed JSON in responseBody', () => { + const error = { + message: 'Error occurred', + responseBody: '{"invalid": json}' + }; + render(); + + expect(screen.getByText('Error occurred')).toBeInTheDocument(); + expect(screen.getByText('Show details')).toBeInTheDocument(); + }); + + it('should handle empty responseBody', () => { + const error = { + message: 'Error occurred', + responseBody: '' + }; + render(); + + expect(screen.getByText('Error occurred')).toBeInTheDocument(); + expect(screen.queryByText('Show details')).not.toBeInTheDocument(); + }); + }); + + describe('Details collapsible functionality', () => { + it('should expand and collapse details when clicked', () => { + const error = { + responseBody: JSON.stringify({ + message: 'Error message', + details: 'Detailed error information' + }) + }; + render(); + + const detailsElement = screen.getByText('Show details'); + expect(detailsElement).toBeInTheDocument(); + + // Details should not be visible initially + expect(screen.queryByText('Detailed error information')).not.toBeVisible(); + + // Click to expand + fireEvent.click(detailsElement); + expect(screen.getByText('Detailed error information')).toBeVisible(); + + // Click to collapse + fireEvent.click(detailsElement); + expect(screen.queryByText('Detailed error information')).not.toBeVisible(); + }); + + it('should show details in preformatted text', () => { + const details = 'Line 1\nLine 2\n Indented line'; + const error = { + responseBody: JSON.stringify({ + details: details + }) + }; + render(); + + fireEvent.click(screen.getByText('Show details')); + + const preElement = screen.getByText((content, element) => { + return element?.tagName === 'PRE' && content.includes('Line 1'); + }); + expect(preElement.tagName).toBe('PRE'); + expect(preElement).toHaveClass('text-xs', 'text-red-700', 'overflow-x-auto', 'bg-red-100', 'p-2', 'rounded'); + }); + + it('should show non-JSON responseBody as details', () => { + const responseBody = 'Stack trace:\n at function1()\n at function2()'; + const error = { + message: 'Runtime error', + responseBody: responseBody + }; + render(); + + fireEvent.click(screen.getByText('Show details')); + expect(screen.getByText((content, element) => { + return element?.tagName === 'PRE' && content.includes('Stack trace'); + })).toBeInTheDocument(); + }); + }); + + describe('Custom className prop', () => { + it('should apply custom className', () => { + const error = { message: 'Test error' }; + const { container } = render(); + + const errorContainer = container.firstChild as HTMLElement; + expect(errorContainer).toHaveClass('custom-class'); + }); + + it('should preserve base classes when custom className is provided', () => { + const error = { message: 'Test error' }; + const { container } = render(); + + const errorContainer = container.firstChild as HTMLElement; + expect(errorContainer).toHaveClass('rounded-lg', 'border', 'border-red-300', 'bg-red-50', 'p-4', 'custom-class'); + }); + + it('should work without custom className', () => { + const error = { message: 'Test error' }; + const { container } = render(); + + const errorContainer = container.firstChild as HTMLElement; + expect(errorContainer).toHaveClass('rounded-lg', 'border', 'border-red-300', 'bg-red-50', 'p-4'); + }); + }); + + describe('Styling and structure', () => { + it('should have correct container structure and styles', () => { + const error = { message: 'Test error', statusCode: 404 }; + const { container } = render(); + + const errorContainer = container.firstChild as HTMLElement; + expect(errorContainer).toHaveClass('rounded-lg', 'border', 'border-red-300', 'bg-red-50', 'p-4'); + }); + + it('should have correct icon placement and styles', () => { + const error = { message: 'Test error' }; + render(); + + const icon = screen.getByTestId('alert-triangle'); + expect(icon).toHaveClass('h-5', 'w-5', 'text-red-600', 'flex-shrink-0', 'mt-0.5'); + }); + + it('should have correct title styles', () => { + const error = { message: 'Test error', statusCode: 500 }; + render(); + + const title = screen.getByText('API Error (500)'); + expect(title).toHaveClass('font-medium', 'text-red-900'); + }); + + it('should have correct message styles', () => { + const error = { message: 'Test error message' }; + render(); + + const message = screen.getByText('Test error message'); + expect(message).toHaveClass('mt-1', 'text-sm', 'text-red-800'); + }); + + it('should have correct details summary styles', () => { + const error = { + responseBody: JSON.stringify({ details: 'Some details' }) + }; + render(); + + const summary = screen.getByText('Show details'); + expect(summary).toHaveClass('cursor-pointer', 'text-sm', 'text-red-700', 'hover:text-red-900'); + }); + }); + + describe('Edge cases and empty states', () => { + it('should handle undefined error properties', () => { + const error = { + message: undefined, + statusCode: undefined, + responseBody: undefined + }; + render(); + + expect(screen.getByText('API Error')).toBeInTheDocument(); + expect(screen.getByText('An error occurred')).toBeInTheDocument(); + expect(screen.queryByText('Show details')).not.toBeInTheDocument(); + }); + + it('should handle null responseBody', () => { + const error = { + message: 'Test error', + responseBody: null as unknown as string + }; + render(); + + expect(screen.getByText('Test error')).toBeInTheDocument(); + expect(screen.queryByText('Show details')).not.toBeInTheDocument(); + }); + + it('should handle zero status code', () => { + const error = { + message: 'Connection error', + statusCode: 0 + }; + render(); + + // Status code 0 is falsy, so it won't be displayed + expect(screen.getByText('API Error')).toBeInTheDocument(); + expect(screen.queryByText('API Error (0)')).not.toBeInTheDocument(); + }); + + it('should handle very long error messages', () => { + const longMessage = 'This is a very '.repeat(50) + 'long error message'; + const error = { message: longMessage }; + render(); + + expect(screen.getByText(longMessage)).toBeInTheDocument(); + }); + + it('should handle special characters in error message', () => { + const error = { message: 'Error with special chars: <>{}[]()&' }; + render(); + + expect(screen.getByText('Error with special chars: <>{}[]()&')).toBeInTheDocument(); + }); + }); + + describe('Complex responseBody scenarios', () => { + it('should handle nested JSON objects in responseBody', () => { + const error = { + responseBody: JSON.stringify({ + message: 'Validation failed', + details: JSON.stringify({ + field: 'email', + errors: ['Invalid format', 'Already exists'] + }) + }) + }; + render(); + + expect(screen.getByText('Validation failed')).toBeInTheDocument(); + + fireEvent.click(screen.getByText('Show details')); + const detailsText = screen.getByText((content, element) => { + return element?.tagName === 'PRE' && content.includes('email'); + }).textContent; + expect(detailsText).toContain('email'); + expect(detailsText).toContain('Invalid format'); + expect(detailsText).toContain('Already exists'); + }); + + it('should handle arrays in JSON responseBody', () => { + const error = { + responseBody: JSON.stringify({ + message: 'Multiple errors', + details: JSON.stringify(['Error 1', 'Error 2', 'Error 3']) + }) + }; + render(); + + expect(screen.getByText('Multiple errors')).toBeInTheDocument(); + + fireEvent.click(screen.getByText('Show details')); + const detailsText = screen.getByText((content, element) => { + return element?.tagName === 'PRE' && content.includes('Error 1'); + }).textContent; + expect(detailsText).toContain('Error 1'); + expect(detailsText).toContain('Error 2'); + expect(detailsText).toContain('Error 3'); + }); + + it('should handle boolean and number values in JSON', () => { + const error = { + responseBody: JSON.stringify({ + message: 'Configuration error', + details: JSON.stringify({ + enabled: false, + retryCount: 3, + timeout: null + }) + }) + }; + render(); + + fireEvent.click(screen.getByText('Show details')); + const detailsText = screen.getByText((content, element) => { + return element?.tagName === 'PRE' && content.includes('enabled'); + }).textContent; + expect(detailsText).toContain('false'); + expect(detailsText).toContain('3'); + expect(detailsText).toContain('null'); + }); + }); + + describe('Component updates and re-renders', () => { + it('should update when error changes', () => { + const { rerender } = render( + + ); + + expect(screen.getByText('First error')).toBeInTheDocument(); + + rerender(); + + expect(screen.queryByText('First error')).not.toBeInTheDocument(); + expect(screen.getByText('Second error')).toBeInTheDocument(); + }); + + it('should update details when responseBody changes', () => { + const { rerender } = render( + + ); + + fireEvent.click(screen.getByText('Show details')); + expect(screen.getByText('First details')).toBeInTheDocument(); + + rerender(); + + expect(screen.queryByText('First details')).not.toBeInTheDocument(); + expect(screen.getByText('Second details')).toBeInTheDocument(); + }); + + it('should handle className updates', () => { + const { rerender, container } = render( + + ); + + let errorContainer = container.firstChild as HTMLElement; + expect(errorContainer).toHaveClass('first-class'); + + rerender(); + + errorContainer = container.firstChild as HTMLElement; + expect(errorContainer).not.toHaveClass('first-class'); + expect(errorContainer).toHaveClass('second-class'); + }); + }); +}); \ No newline at end of file diff --git a/components/chat/ApiErrorDisplay.tsx b/components/chat/ApiErrorDisplay.tsx index 20dcd90..2f5a694 100644 --- a/components/chat/ApiErrorDisplay.tsx +++ b/components/chat/ApiErrorDisplay.tsx @@ -38,7 +38,7 @@ export default function ApiErrorDisplay({ className={`rounded-lg border border-red-300 bg-red-50 p-4 ${className}`} >
- +

API Error {error.statusCode ? `(${error.statusCode})` : ""}