Add comprehensive performance monitoring and testing infrastructure
- Implement performance dashboard with real-time metrics tracking - Add React hooks for smart polling, debouncing, and active source lookup - Create Jest testing framework with comprehensive test suites for components, API endpoints, and utilities - Enhance UI components with optimized rendering and memoization - Improve polling efficiency with visibility detection and adaptive intervals 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
a66979fb34
commit
c259f0d943
15 changed files with 6320 additions and 94 deletions
252
lib/__tests__/apiHelpers.test.ts
Normal file
252
lib/__tests__/apiHelpers.test.ts
Normal file
|
@ -0,0 +1,252 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
import {
|
||||
createErrorResponse,
|
||||
createSuccessResponse,
|
||||
createValidationError,
|
||||
createDatabaseError,
|
||||
createOBSError,
|
||||
parseRequestBody,
|
||||
} from '../apiHelpers';
|
||||
|
||||
// Mock NextResponse
|
||||
jest.mock('next/server', () => ({
|
||||
NextResponse: {
|
||||
json: jest.fn((data, options) => ({
|
||||
data,
|
||||
status: options?.status || 200,
|
||||
})),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock console.error to silence expected error logs
|
||||
const mockConsoleError = jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
describe('apiHelpers', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
mockConsoleError.mockRestore();
|
||||
});
|
||||
|
||||
describe('createErrorResponse', () => {
|
||||
it('creates error response with default status 500', () => {
|
||||
const response = createErrorResponse('Test Error');
|
||||
|
||||
expect(NextResponse.json).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
error: 'Test Error',
|
||||
timestamp: expect.any(String),
|
||||
}),
|
||||
{ status: 500 }
|
||||
);
|
||||
});
|
||||
|
||||
it('creates error response with custom status and message', () => {
|
||||
const response = createErrorResponse('Test Error', 400, 'Custom message', { detail: 'extra' });
|
||||
|
||||
expect(NextResponse.json).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
error: 'Test Error',
|
||||
message: 'Custom message',
|
||||
details: { detail: 'extra' },
|
||||
timestamp: expect.any(String),
|
||||
}),
|
||||
{ status: 400 }
|
||||
);
|
||||
});
|
||||
|
||||
it('logs error to console', () => {
|
||||
// Temporarily restore the mock to capture calls
|
||||
mockConsoleError.mockRestore();
|
||||
const tempMock = jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
createErrorResponse('Test Error', 400);
|
||||
|
||||
expect(tempMock).toHaveBeenCalledWith(
|
||||
'API Error [400]:',
|
||||
expect.objectContaining({
|
||||
error: 'Test Error',
|
||||
timestamp: expect.any(String),
|
||||
})
|
||||
);
|
||||
|
||||
// Restore the original mock
|
||||
tempMock.mockRestore();
|
||||
jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
});
|
||||
});
|
||||
|
||||
describe('createSuccessResponse', () => {
|
||||
it('creates success response with default status 200', () => {
|
||||
const data = { test: 'data' };
|
||||
const response = createSuccessResponse(data);
|
||||
|
||||
expect(NextResponse.json).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
success: true,
|
||||
data: { test: 'data' },
|
||||
timestamp: expect.any(String),
|
||||
}),
|
||||
{ status: 200 }
|
||||
);
|
||||
});
|
||||
|
||||
it('creates success response with custom status', () => {
|
||||
const data = { id: 1, name: 'test' };
|
||||
const response = createSuccessResponse(data, 201);
|
||||
|
||||
expect(NextResponse.json).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
success: true,
|
||||
data: { id: 1, name: 'test' },
|
||||
timestamp: expect.any(String),
|
||||
}),
|
||||
{ status: 201 }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('specialized error responses', () => {
|
||||
it('createValidationError creates 400 response', () => {
|
||||
const details = { field: 'error message' };
|
||||
createValidationError('Validation failed', details);
|
||||
|
||||
expect(NextResponse.json).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
error: 'Validation Error',
|
||||
message: 'Validation failed',
|
||||
details,
|
||||
}),
|
||||
{ status: 400 }
|
||||
);
|
||||
});
|
||||
|
||||
it('createDatabaseError creates 500 response', () => {
|
||||
const originalError = new Error('DB connection failed');
|
||||
createDatabaseError('fetch users', originalError);
|
||||
|
||||
expect(NextResponse.json).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
error: 'Database Error',
|
||||
message: 'Database operation failed: fetch users',
|
||||
}),
|
||||
{ status: 500 }
|
||||
);
|
||||
});
|
||||
|
||||
it('createOBSError creates 502 response', () => {
|
||||
const originalError = new Error('WebSocket failed');
|
||||
createOBSError('connect to OBS', originalError);
|
||||
|
||||
expect(NextResponse.json).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
error: 'OBS Error',
|
||||
message: 'OBS operation failed: connect to OBS',
|
||||
}),
|
||||
{ status: 502 }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseRequestBody', () => {
|
||||
const mockRequest = (body: any): Request => ({
|
||||
json: jest.fn().mockResolvedValue(body),
|
||||
} as any);
|
||||
|
||||
it('parses valid JSON body without validator', async () => {
|
||||
const body = { name: 'test', value: 123 };
|
||||
const request = mockRequest(body);
|
||||
|
||||
const result = await parseRequestBody(request);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toEqual(body);
|
||||
}
|
||||
});
|
||||
|
||||
it('handles invalid JSON', async () => {
|
||||
const request = {
|
||||
json: jest.fn().mockRejectedValue(new Error('Invalid JSON')),
|
||||
} as any;
|
||||
|
||||
const result = await parseRequestBody(request);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.response).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('validates body with custom validator', async () => {
|
||||
const body = { name: 'test' };
|
||||
const request = mockRequest(body);
|
||||
|
||||
const validator = jest.fn().mockReturnValue({
|
||||
valid: true,
|
||||
data: { name: 'test' },
|
||||
});
|
||||
|
||||
const result = await parseRequestBody(request, validator);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(validator).toHaveBeenCalledWith(body);
|
||||
if (result.success) {
|
||||
expect(result.data).toEqual({ name: 'test' });
|
||||
}
|
||||
});
|
||||
|
||||
it('handles validation failure', async () => {
|
||||
const body = { name: '' };
|
||||
const request = mockRequest(body);
|
||||
|
||||
const validator = jest.fn().mockReturnValue({
|
||||
valid: false,
|
||||
errors: { name: 'Name is required' },
|
||||
});
|
||||
|
||||
const result = await parseRequestBody(request, validator);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(validator).toHaveBeenCalledWith(body);
|
||||
});
|
||||
});
|
||||
|
||||
describe('environment-specific behavior', () => {
|
||||
const originalEnv = process.env.NODE_ENV;
|
||||
|
||||
afterAll(() => {
|
||||
process.env.NODE_ENV = originalEnv;
|
||||
});
|
||||
|
||||
it('includes error details in development', () => {
|
||||
process.env.NODE_ENV = 'development';
|
||||
const originalError = new Error('Test error');
|
||||
|
||||
createDatabaseError('test operation', originalError);
|
||||
|
||||
expect(NextResponse.json).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
details: originalError,
|
||||
}),
|
||||
{ status: 500 }
|
||||
);
|
||||
});
|
||||
|
||||
it('excludes error details in production', () => {
|
||||
process.env.NODE_ENV = 'production';
|
||||
const originalError = new Error('Test error');
|
||||
|
||||
createDatabaseError('test operation', originalError);
|
||||
|
||||
expect(NextResponse.json).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
details: undefined,
|
||||
}),
|
||||
{ status: 500 }
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
226
lib/__tests__/useToast.test.ts
Normal file
226
lib/__tests__/useToast.test.ts
Normal file
|
@ -0,0 +1,226 @@
|
|||
import { renderHook, act } from '@testing-library/react';
|
||||
import { useToast } from '../useToast';
|
||||
|
||||
describe('useToast', () => {
|
||||
let mockRandom: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset Math.random to ensure consistent IDs in tests
|
||||
mockRandom = jest.spyOn(Math, 'random').mockReturnValue(0.5);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockRandom.mockRestore();
|
||||
});
|
||||
|
||||
it('starts with empty toasts array', () => {
|
||||
const { result } = renderHook(() => useToast());
|
||||
|
||||
expect(result.current.toasts).toEqual([]);
|
||||
});
|
||||
|
||||
it('adds a toast with addToast', () => {
|
||||
const { result } = renderHook(() => useToast());
|
||||
|
||||
act(() => {
|
||||
result.current.addToast('info', 'Test Title', 'Test message');
|
||||
});
|
||||
|
||||
expect(result.current.toasts).toHaveLength(1);
|
||||
expect(result.current.toasts[0]).toMatchObject({
|
||||
type: 'info',
|
||||
title: 'Test Title',
|
||||
message: 'Test message',
|
||||
duration: 5000,
|
||||
});
|
||||
expect(result.current.toasts[0].id).toBeDefined();
|
||||
});
|
||||
|
||||
it('adds error toast with longer duration', () => {
|
||||
const { result } = renderHook(() => useToast());
|
||||
|
||||
act(() => {
|
||||
result.current.addToast('error', 'Error Title');
|
||||
});
|
||||
|
||||
expect(result.current.toasts[0]).toMatchObject({
|
||||
type: 'error',
|
||||
title: 'Error Title',
|
||||
duration: 7000, // Errors stay longer
|
||||
});
|
||||
});
|
||||
|
||||
it('adds toast with custom duration', () => {
|
||||
const { result } = renderHook(() => useToast());
|
||||
|
||||
act(() => {
|
||||
result.current.addToast('success', 'Success Title', 'Success message', 3000);
|
||||
});
|
||||
|
||||
expect(result.current.toasts[0]).toMatchObject({
|
||||
type: 'success',
|
||||
title: 'Success Title',
|
||||
message: 'Success message',
|
||||
duration: 3000,
|
||||
});
|
||||
});
|
||||
|
||||
it('removes a toast by ID', () => {
|
||||
const { result } = renderHook(() => useToast());
|
||||
|
||||
let toastId: string;
|
||||
|
||||
act(() => {
|
||||
toastId = result.current.addToast('info', 'Test Title');
|
||||
});
|
||||
|
||||
expect(result.current.toasts).toHaveLength(1);
|
||||
|
||||
act(() => {
|
||||
result.current.removeToast(toastId);
|
||||
});
|
||||
|
||||
expect(result.current.toasts).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('clears all toasts', () => {
|
||||
const { result } = renderHook(() => useToast());
|
||||
|
||||
act(() => {
|
||||
result.current.addToast('info', 'Toast 1');
|
||||
result.current.addToast('error', 'Toast 2');
|
||||
result.current.addToast('success', 'Toast 3');
|
||||
});
|
||||
|
||||
expect(result.current.toasts).toHaveLength(3);
|
||||
|
||||
act(() => {
|
||||
result.current.clearAllToasts();
|
||||
});
|
||||
|
||||
expect(result.current.toasts).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('supports multiple toasts', () => {
|
||||
const { result } = renderHook(() => useToast());
|
||||
|
||||
act(() => {
|
||||
result.current.addToast('info', 'Toast 1');
|
||||
result.current.addToast('error', 'Toast 2');
|
||||
result.current.addToast('success', 'Toast 3');
|
||||
});
|
||||
|
||||
expect(result.current.toasts).toHaveLength(3);
|
||||
expect(result.current.toasts[0].title).toBe('Toast 1');
|
||||
expect(result.current.toasts[1].title).toBe('Toast 2');
|
||||
expect(result.current.toasts[2].title).toBe('Toast 3');
|
||||
});
|
||||
|
||||
describe('convenience methods', () => {
|
||||
it('showSuccess creates success toast', () => {
|
||||
const { result } = renderHook(() => useToast());
|
||||
|
||||
act(() => {
|
||||
result.current.showSuccess('Success!', 'Operation completed');
|
||||
});
|
||||
|
||||
expect(result.current.toasts[0]).toMatchObject({
|
||||
type: 'success',
|
||||
title: 'Success!',
|
||||
message: 'Operation completed',
|
||||
});
|
||||
});
|
||||
|
||||
it('showError creates error toast', () => {
|
||||
const { result } = renderHook(() => useToast());
|
||||
|
||||
act(() => {
|
||||
result.current.showError('Error!', 'Something went wrong');
|
||||
});
|
||||
|
||||
expect(result.current.toasts[0]).toMatchObject({
|
||||
type: 'error',
|
||||
title: 'Error!',
|
||||
message: 'Something went wrong',
|
||||
duration: 7000,
|
||||
});
|
||||
});
|
||||
|
||||
it('showWarning creates warning toast', () => {
|
||||
const { result } = renderHook(() => useToast());
|
||||
|
||||
act(() => {
|
||||
result.current.showWarning('Warning!', 'Be careful');
|
||||
});
|
||||
|
||||
expect(result.current.toasts[0]).toMatchObject({
|
||||
type: 'warning',
|
||||
title: 'Warning!',
|
||||
message: 'Be careful',
|
||||
});
|
||||
});
|
||||
|
||||
it('showInfo creates info toast', () => {
|
||||
const { result } = renderHook(() => useToast());
|
||||
|
||||
act(() => {
|
||||
result.current.showInfo('Info', 'Helpful information');
|
||||
});
|
||||
|
||||
expect(result.current.toasts[0]).toMatchObject({
|
||||
type: 'info',
|
||||
title: 'Info',
|
||||
message: 'Helpful information',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('returns unique IDs for each toast', () => {
|
||||
const { result } = renderHook(() => useToast());
|
||||
|
||||
let id1: string, id2: string;
|
||||
|
||||
act(() => {
|
||||
// Mock different random values for unique IDs
|
||||
mockRandom
|
||||
.mockReturnValueOnce(0.1)
|
||||
.mockReturnValueOnce(0.9);
|
||||
|
||||
id1 = result.current.addToast('info', 'Toast 1');
|
||||
id2 = result.current.addToast('info', 'Toast 2');
|
||||
});
|
||||
|
||||
expect(id1).not.toBe(id2);
|
||||
expect(result.current.toasts[0].id).toBe(id1);
|
||||
expect(result.current.toasts[1].id).toBe(id2);
|
||||
});
|
||||
|
||||
it('removes only the specified toast when multiple exist', () => {
|
||||
const { result } = renderHook(() => useToast());
|
||||
|
||||
let id1: string, id2: string, id3: string;
|
||||
|
||||
act(() => {
|
||||
// Mock different random values for unique IDs
|
||||
mockRandom
|
||||
.mockReturnValueOnce(0.1)
|
||||
.mockReturnValueOnce(0.5)
|
||||
.mockReturnValueOnce(0.9);
|
||||
|
||||
id1 = result.current.addToast('info', 'Toast 1');
|
||||
id2 = result.current.addToast('error', 'Toast 2');
|
||||
id3 = result.current.addToast('success', 'Toast 3');
|
||||
});
|
||||
|
||||
expect(result.current.toasts).toHaveLength(3);
|
||||
|
||||
act(() => {
|
||||
result.current.removeToast(id2);
|
||||
});
|
||||
|
||||
expect(result.current.toasts).toHaveLength(2);
|
||||
expect(result.current.toasts.find(t => t.id === id1)).toBeDefined();
|
||||
expect(result.current.toasts.find(t => t.id === id2)).toBeUndefined();
|
||||
expect(result.current.toasts.find(t => t.id === id3)).toBeDefined();
|
||||
});
|
||||
});
|
189
lib/performance.ts
Normal file
189
lib/performance.ts
Normal file
|
@ -0,0 +1,189 @@
|
|||
// Performance utilities and hooks for optimization
|
||||
|
||||
import React, { useMemo, useCallback, useRef } from 'react';
|
||||
|
||||
// Debounce hook for preventing excessive API calls
|
||||
export function useDebounce<T extends (...args: any[]) => any>(
|
||||
callback: T,
|
||||
delay: number
|
||||
): T {
|
||||
const timeoutRef = useRef<NodeJS.Timeout>();
|
||||
|
||||
return useCallback((...args: Parameters<T>) => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
callback(...args);
|
||||
}, delay);
|
||||
}, [callback, delay]) as T;
|
||||
}
|
||||
|
||||
// Throttle hook for limiting function calls
|
||||
export function useThrottle<T extends (...args: any[]) => any>(
|
||||
callback: T,
|
||||
delay: number
|
||||
): T {
|
||||
const lastCallRef = useRef<number>(0);
|
||||
|
||||
return useCallback((...args: Parameters<T>) => {
|
||||
const now = Date.now();
|
||||
if (now - lastCallRef.current >= delay) {
|
||||
lastCallRef.current = now;
|
||||
callback(...args);
|
||||
}
|
||||
}, [callback, delay]) as T;
|
||||
}
|
||||
|
||||
// Memoized stream lookup utilities
|
||||
export function createStreamLookupMaps(streams: Array<{ id: number; obs_source_name: string; name: string }>) {
|
||||
return useMemo(() => {
|
||||
const sourceToIdMap = new Map<string, number>();
|
||||
const idToStreamMap = new Map<number, { id: number; obs_source_name: string; name: string }>();
|
||||
|
||||
streams.forEach(stream => {
|
||||
sourceToIdMap.set(stream.obs_source_name, stream.id);
|
||||
idToStreamMap.set(stream.id, stream);
|
||||
});
|
||||
|
||||
return { sourceToIdMap, idToStreamMap };
|
||||
}, [streams]);
|
||||
}
|
||||
|
||||
// Efficient active source lookup
|
||||
export function useActiveSourceLookup(
|
||||
streams: Array<{ id: number; obs_source_name: string; name: string }>,
|
||||
activeSources: Record<string, string | null>
|
||||
) {
|
||||
const { sourceToIdMap } = createStreamLookupMaps(streams);
|
||||
|
||||
return useMemo(() => {
|
||||
const activeSourceIds: Record<string, number | null> = {};
|
||||
|
||||
Object.entries(activeSources).forEach(([screen, sourceName]) => {
|
||||
activeSourceIds[screen] = sourceName ? sourceToIdMap.get(sourceName) || null : null;
|
||||
});
|
||||
|
||||
return activeSourceIds;
|
||||
}, [activeSources, sourceToIdMap]);
|
||||
}
|
||||
|
||||
// Performance monitoring utilities
|
||||
export class PerformanceMonitor {
|
||||
private static metrics: Map<string, number[]> = new Map();
|
||||
|
||||
static startTimer(label: string): () => void {
|
||||
const start = performance.now();
|
||||
|
||||
return () => {
|
||||
const duration = performance.now() - start;
|
||||
|
||||
if (!this.metrics.has(label)) {
|
||||
this.metrics.set(label, []);
|
||||
}
|
||||
|
||||
this.metrics.get(label)!.push(duration);
|
||||
|
||||
// Keep only last 100 measurements
|
||||
if (this.metrics.get(label)!.length > 100) {
|
||||
this.metrics.get(label)!.shift();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
static getMetrics(label: string) {
|
||||
const measurements = this.metrics.get(label) || [];
|
||||
if (measurements.length === 0) return null;
|
||||
|
||||
const avg = measurements.reduce((a, b) => a + b, 0) / measurements.length;
|
||||
const min = Math.min(...measurements);
|
||||
const max = Math.max(...measurements);
|
||||
|
||||
return { avg, min, max, count: measurements.length };
|
||||
}
|
||||
|
||||
static getAllMetrics() {
|
||||
const result: Record<string, any> = {};
|
||||
this.metrics.forEach((_, label) => {
|
||||
result[label] = this.getMetrics(label);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// React component performance wrapper
|
||||
export function withPerformanceMonitoring<P extends object>(
|
||||
Component: React.ComponentType<P>,
|
||||
componentName: string
|
||||
): React.ComponentType<P> {
|
||||
return function PerformanceMonitoredComponent(props: P) {
|
||||
const endTimer = PerformanceMonitor.startTimer(`${componentName}_render`);
|
||||
|
||||
try {
|
||||
const result = React.createElement(Component, props);
|
||||
endTimer();
|
||||
return result;
|
||||
} catch (error) {
|
||||
endTimer();
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Visibility API hook for pausing updates when not visible
|
||||
export function usePageVisibility() {
|
||||
const [isVisible, setIsVisible] = React.useState(true);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (typeof document === 'undefined') return; // SSR check
|
||||
|
||||
const handleVisibilityChange = () => {
|
||||
setIsVisible(!document.hidden);
|
||||
};
|
||||
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return isVisible;
|
||||
}
|
||||
|
||||
// Smart polling hook that respects visibility and connection status
|
||||
export function useSmartPolling(
|
||||
callback: () => void | Promise<void>,
|
||||
interval: number,
|
||||
dependencies: any[] = []
|
||||
) {
|
||||
const isVisible = usePageVisibility();
|
||||
const callbackRef = useRef(callback);
|
||||
const intervalRef = useRef<NodeJS.Timeout>();
|
||||
|
||||
// Update callback ref
|
||||
React.useEffect(() => {
|
||||
callbackRef.current = callback;
|
||||
}, [callback]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isVisible) {
|
||||
// Start polling when visible
|
||||
callbackRef.current();
|
||||
intervalRef.current = setInterval(() => {
|
||||
callbackRef.current();
|
||||
}, interval);
|
||||
} else {
|
||||
// Stop polling when not visible
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
}
|
||||
};
|
||||
}, [interval, isVisible, ...dependencies]);
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue