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
76
app/api/__tests__/streams.test.ts
Normal file
76
app/api/__tests__/streams.test.ts
Normal file
|
@ -0,0 +1,76 @@
|
|||
import { GET } from '../streams/route';
|
||||
|
||||
// Mock the database module
|
||||
jest.mock('@/lib/database', () => ({
|
||||
getDatabase: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('/api/streams', () => {
|
||||
let mockDb: any;
|
||||
|
||||
beforeEach(() => {
|
||||
// Create mock database
|
||||
mockDb = {
|
||||
all: jest.fn(),
|
||||
};
|
||||
|
||||
const { getDatabase } = require('@/lib/database');
|
||||
getDatabase.mockResolvedValue(mockDb);
|
||||
});
|
||||
|
||||
describe('GET /api/streams', () => {
|
||||
it('returns all streams successfully', async () => {
|
||||
const mockStreams = [
|
||||
{ id: 1, name: 'Stream 1', url: 'http://example.com/1', obs_source_name: 'Source 1', team_id: 1 },
|
||||
{ id: 2, name: 'Stream 2', url: 'http://example.com/2', obs_source_name: 'Source 2', team_id: 2 },
|
||||
];
|
||||
|
||||
mockDb.all.mockResolvedValue(mockStreams);
|
||||
|
||||
const response = await GET();
|
||||
|
||||
expect(mockDb.all).toHaveBeenCalledWith(
|
||||
expect.stringContaining('SELECT * FROM')
|
||||
);
|
||||
|
||||
const { NextResponse } = require('next/server');
|
||||
expect(NextResponse.json).toHaveBeenCalledWith(mockStreams);
|
||||
});
|
||||
|
||||
it('returns empty array when no streams exist', async () => {
|
||||
mockDb.all.mockResolvedValue([]);
|
||||
|
||||
const response = await GET();
|
||||
|
||||
const { NextResponse } = require('next/server');
|
||||
expect(NextResponse.json).toHaveBeenCalledWith([]);
|
||||
});
|
||||
|
||||
it('handles database errors gracefully', async () => {
|
||||
const dbError = new Error('Database connection failed');
|
||||
mockDb.all.mockRejectedValue(dbError);
|
||||
|
||||
const response = await GET();
|
||||
|
||||
const { NextResponse } = require('next/server');
|
||||
expect(NextResponse.json).toHaveBeenCalledWith(
|
||||
{ error: 'Failed to fetch streams' },
|
||||
{ status: 500 }
|
||||
);
|
||||
});
|
||||
|
||||
it('handles database connection errors', async () => {
|
||||
const connectionError = new Error('Failed to connect to database');
|
||||
const { getDatabase } = require('@/lib/database');
|
||||
getDatabase.mockRejectedValue(connectionError);
|
||||
|
||||
const response = await GET();
|
||||
|
||||
const { NextResponse } = require('next/server');
|
||||
expect(NextResponse.json).toHaveBeenCalledWith(
|
||||
{ error: 'Failed to fetch streams' },
|
||||
{ status: 500 }
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
89
app/api/__tests__/teams.test.ts
Normal file
89
app/api/__tests__/teams.test.ts
Normal file
|
@ -0,0 +1,89 @@
|
|||
import { GET } from '../teams/route';
|
||||
|
||||
// Mock the database module
|
||||
jest.mock('@/lib/database', () => ({
|
||||
getDatabase: jest.fn(),
|
||||
}));
|
||||
|
||||
// Mock the apiHelpers module
|
||||
jest.mock('@/lib/apiHelpers', () => ({
|
||||
withErrorHandling: jest.fn((handler) => handler),
|
||||
createSuccessResponse: jest.fn((data, status = 200) => ({
|
||||
data,
|
||||
status,
|
||||
json: async () => ({ success: true, data }),
|
||||
})),
|
||||
createDatabaseError: jest.fn((operation, error) => ({
|
||||
error: 'Database Error',
|
||||
status: 500,
|
||||
json: async () => ({
|
||||
error: 'Database Error',
|
||||
message: `Database operation failed: ${operation}`,
|
||||
}),
|
||||
})),
|
||||
}));
|
||||
|
||||
describe('/api/teams', () => {
|
||||
let mockDb: any;
|
||||
|
||||
beforeEach(() => {
|
||||
// Create mock database
|
||||
mockDb = {
|
||||
all: jest.fn(),
|
||||
};
|
||||
|
||||
const { getDatabase } = require('@/lib/database');
|
||||
getDatabase.mockResolvedValue(mockDb);
|
||||
});
|
||||
|
||||
describe('GET /api/teams', () => {
|
||||
it('returns all teams successfully', async () => {
|
||||
const mockTeams = [
|
||||
{ team_id: 1, team_name: 'Team Alpha' },
|
||||
{ team_id: 2, team_name: 'Team Beta' },
|
||||
{ team_id: 3, team_name: 'Team Gamma' },
|
||||
];
|
||||
|
||||
mockDb.all.mockResolvedValue(mockTeams);
|
||||
|
||||
const response = await GET();
|
||||
|
||||
expect(mockDb.all).toHaveBeenCalledWith(
|
||||
expect.stringContaining('SELECT * FROM')
|
||||
);
|
||||
|
||||
const { createSuccessResponse } = require('@/lib/apiHelpers');
|
||||
expect(createSuccessResponse).toHaveBeenCalledWith(mockTeams);
|
||||
});
|
||||
|
||||
it('returns empty array when no teams exist', async () => {
|
||||
mockDb.all.mockResolvedValue([]);
|
||||
|
||||
const response = await GET();
|
||||
|
||||
const { createSuccessResponse } = require('@/lib/apiHelpers');
|
||||
expect(createSuccessResponse).toHaveBeenCalledWith([]);
|
||||
});
|
||||
|
||||
it('handles database errors gracefully', async () => {
|
||||
const dbError = new Error('Table does not exist');
|
||||
mockDb.all.mockRejectedValue(dbError);
|
||||
|
||||
const response = await GET();
|
||||
|
||||
const { createDatabaseError } = require('@/lib/apiHelpers');
|
||||
expect(createDatabaseError).toHaveBeenCalledWith('fetch teams', dbError);
|
||||
});
|
||||
|
||||
it('handles database connection errors', async () => {
|
||||
const connectionError = new Error('Failed to connect to database');
|
||||
const { getDatabase } = require('@/lib/database');
|
||||
getDatabase.mockRejectedValue(connectionError);
|
||||
|
||||
const response = await GET();
|
||||
|
||||
const { createDatabaseError } = require('@/lib/apiHelpers');
|
||||
expect(createDatabaseError).toHaveBeenCalledWith('fetch teams', connectionError);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -2,6 +2,7 @@ import './globals.css';
|
|||
import Header from '@/components/Header';
|
||||
import Footer from '@/components/Footer';
|
||||
import { ErrorBoundary } from '@/components/ErrorBoundary';
|
||||
import PerformanceDashboard from '@/components/PerformanceDashboard';
|
||||
|
||||
export const metadata = {
|
||||
title: 'OBS Source Switcher',
|
||||
|
@ -19,6 +20,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
|||
</ErrorBoundary>
|
||||
</main>
|
||||
<Footer />
|
||||
<PerformanceDashboard />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
|
125
app/page.tsx
125
app/page.tsx
|
@ -1,10 +1,11 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import Link from 'next/link';
|
||||
import Dropdown from '@/components/Dropdown';
|
||||
import { useToast } from '@/lib/useToast';
|
||||
import { ToastContainer } from '@/components/Toast';
|
||||
import { useActiveSourceLookup, useDebounce, PerformanceMonitor } from '@/lib/performance';
|
||||
|
||||
type Stream = {
|
||||
id: number;
|
||||
|
@ -30,50 +31,21 @@ export default function Home() {
|
|||
const [openDropdown, setOpenDropdown] = useState<string | null>(null);
|
||||
const { toasts, removeToast, showSuccess, showError } = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
// Fetch streams and active sources in parallel
|
||||
const [streamsRes, activeRes] = await Promise.all([
|
||||
fetch('/api/streams'),
|
||||
fetch('/api/getActive')
|
||||
]);
|
||||
|
||||
const [streamsData, activeData] = await Promise.all([
|
||||
streamsRes.json(),
|
||||
activeRes.json()
|
||||
]);
|
||||
|
||||
setStreams(streamsData);
|
||||
setActiveSources(activeData);
|
||||
} catch (error) {
|
||||
console.error('Error fetching data:', error);
|
||||
showError('Failed to Load Data', 'Could not fetch streams. Please refresh the page.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
// Memoized active source lookup for performance
|
||||
const activeSourceIds = useActiveSourceLookup(streams, activeSources);
|
||||
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const handleSetActive = async (screen: ScreenType, id: number | null) => {
|
||||
const selectedStream = streams.find((stream) => stream.id === id);
|
||||
|
||||
// Update local state immediately
|
||||
setActiveSources((prev) => ({
|
||||
...prev,
|
||||
[screen]: selectedStream?.obs_source_name || null,
|
||||
}));
|
||||
|
||||
// Update backend
|
||||
// Debounced API calls to prevent excessive requests
|
||||
const debouncedSetActive = useDebounce(async (screen: ScreenType, id: number | null) => {
|
||||
if (id) {
|
||||
const selectedStream = streams.find(stream => stream.id === id);
|
||||
try {
|
||||
const endTimer = PerformanceMonitor.startTimer('setActive_api');
|
||||
const response = await fetch('/api/setActive', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ screen, id }),
|
||||
});
|
||||
endTimer();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to set active stream');
|
||||
|
@ -90,11 +62,61 @@ export default function Home() {
|
|||
}));
|
||||
}
|
||||
}
|
||||
};
|
||||
}, 300);
|
||||
|
||||
const handleToggleDropdown = (screen: string) => {
|
||||
const fetchData = useCallback(async () => {
|
||||
const endTimer = PerformanceMonitor.startTimer('fetchData');
|
||||
try {
|
||||
// Fetch streams and active sources in parallel
|
||||
const [streamsRes, activeRes] = await Promise.all([
|
||||
fetch('/api/streams'),
|
||||
fetch('/api/getActive')
|
||||
]);
|
||||
|
||||
const [streamsData, activeData] = await Promise.all([
|
||||
streamsRes.json(),
|
||||
activeRes.json()
|
||||
]);
|
||||
|
||||
setStreams(streamsData);
|
||||
setActiveSources(activeData);
|
||||
} catch (error) {
|
||||
console.error('Error fetching data:', error);
|
||||
showError('Failed to Load Data', 'Could not fetch streams. Please refresh the page.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
endTimer();
|
||||
}
|
||||
}, [showError]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
const handleSetActive = useCallback(async (screen: ScreenType, id: number | null) => {
|
||||
const selectedStream = streams.find((stream) => stream.id === id);
|
||||
|
||||
// Update local state immediately for optimistic updates
|
||||
setActiveSources((prev) => ({
|
||||
...prev,
|
||||
[screen]: selectedStream?.obs_source_name || null,
|
||||
}));
|
||||
|
||||
// Debounced backend update
|
||||
debouncedSetActive(screen, id);
|
||||
}, [streams, debouncedSetActive]);
|
||||
|
||||
const handleToggleDropdown = useCallback((screen: string) => {
|
||||
setOpenDropdown((prev) => (prev === screen ? null : screen));
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Memoized corner displays to prevent re-renders
|
||||
const cornerDisplays = useMemo(() => [
|
||||
{ screen: 'topLeft' as const, label: 'Top Left' },
|
||||
{ screen: 'topRight' as const, label: 'Top Right' },
|
||||
{ screen: 'bottomLeft' as const, label: 'Bottom Left' },
|
||||
{ screen: 'bottomRight' as const, label: 'Bottom Right' },
|
||||
], []);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
|
@ -123,9 +145,7 @@ export default function Home() {
|
|||
<div className="max-w-md mx-auto">
|
||||
<Dropdown
|
||||
options={streams}
|
||||
activeId={
|
||||
streams.find((stream) => stream.obs_source_name === activeSources.large)?.id || null
|
||||
}
|
||||
activeId={activeSourceIds.large}
|
||||
onSelect={(id) => handleSetActive('large', id)}
|
||||
label="Select Primary Stream..."
|
||||
isOpen={openDropdown === 'large'}
|
||||
|
@ -142,9 +162,7 @@ export default function Home() {
|
|||
<h3 className="text-lg font-semibold mb-4 text-center">Left Display</h3>
|
||||
<Dropdown
|
||||
options={streams}
|
||||
activeId={
|
||||
streams.find((stream) => stream.obs_source_name === activeSources.left)?.id || null
|
||||
}
|
||||
activeId={activeSourceIds.left}
|
||||
onSelect={(id) => handleSetActive('left', id)}
|
||||
label="Select Left Stream..."
|
||||
isOpen={openDropdown === 'left'}
|
||||
|
@ -155,9 +173,7 @@ export default function Home() {
|
|||
<h3 className="text-lg font-semibold mb-4 text-center">Right Display</h3>
|
||||
<Dropdown
|
||||
options={streams}
|
||||
activeId={
|
||||
streams.find((stream) => stream.obs_source_name === activeSources.right)?.id || null
|
||||
}
|
||||
activeId={activeSourceIds.right}
|
||||
onSelect={(id) => handleSetActive('right', id)}
|
||||
label="Select Right Stream..."
|
||||
isOpen={openDropdown === 'right'}
|
||||
|
@ -171,19 +187,12 @@ export default function Home() {
|
|||
<div className="glass p-6">
|
||||
<h2 className="card-title">Corner Displays</h2>
|
||||
<div className="grid-4">
|
||||
{[
|
||||
{ screen: 'topLeft' as const, label: 'Top Left' },
|
||||
{ screen: 'topRight' as const, label: 'Top Right' },
|
||||
{ screen: 'bottomLeft' as const, label: 'Bottom Left' },
|
||||
{ screen: 'bottomRight' as const, label: 'Bottom Right' },
|
||||
].map(({ screen, label }) => (
|
||||
{cornerDisplays.map(({ screen, label }) => (
|
||||
<div key={screen}>
|
||||
<h3 className="text-md font-semibold mb-3 text-center">{label}</h3>
|
||||
<Dropdown
|
||||
options={streams}
|
||||
activeId={
|
||||
streams.find((stream) => stream.obs_source_name === activeSources[screen])?.id || null
|
||||
}
|
||||
activeId={activeSourceIds[screen]}
|
||||
onSelect={(id) => handleSetActive(screen, id)}
|
||||
label="Select Stream..."
|
||||
isOpen={openDropdown === screen}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue