Add comprehensive performance monitoring and testing infrastructure
Some checks failed
Lint and Build / build (20) (pull_request) Failing after 36s
Lint and Build / build (22) (pull_request) Failing after 50s

- 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:
Decobus 2025-07-19 06:20:19 -04:00
parent a66979fb34
commit c259f0d943
15 changed files with 6320 additions and 94 deletions

View 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 }
);
});
});
});

View 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
View 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]);
}