diff --git a/packages/shared-components/src/components/CodeBlock.test.tsx b/packages/shared-components/src/components/CodeBlock.test.tsx new file mode 100644 index 0000000..1b7a5f9 --- /dev/null +++ b/packages/shared-components/src/components/CodeBlock.test.tsx @@ -0,0 +1,93 @@ +/** + * @vitest-environment jsdom + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import React from 'react'; +import { render, screen, fireEvent, act } from '@testing-library/react'; +import { CodeBlock } from './CodeBlock'; + +// Mock clipboard API +beforeEach(() => { + Object.assign(navigator, { + clipboard: { + writeText: vi.fn().mockResolvedValue(undefined), + }, + }); +}); + +describe('CodeBlock', () => { + it('renders code content', () => { + render(); + expect(screen.getByText(/const/)).toBeTruthy(); + }); + + it('shows copy button when copyable', () => { + const { container } = render( + + ); + // Should have a button for copy + const buttons = container.querySelectorAll('button'); + expect(buttons.length).toBeGreaterThan(0); + }); + + it('clears copy timeout on unmount (the fix)', () => { + vi.useFakeTimers(); + + const { container, unmount } = render( + + ); + + // Click copy button + const copyButton = container.querySelector('button'); + if (copyButton) { + fireEvent.click(copyButton); + } + + // Unmount before the 2000ms timeout fires + unmount(); + + // Advance timers — should NOT throw "setState on unmounted" + expect(() => { + vi.advanceTimersByTime(3000); + }).not.toThrow(); + + vi.useRealTimers(); + }); + + it('resets copied state after rapid clicks', () => { + vi.useFakeTimers(); + + const { container } = render( + + ); + + const copyButton = container.querySelector('button'); + if (copyButton) { + // Click twice rapidly + fireEvent.click(copyButton); + fireEvent.click(copyButton); + } + + // Only one timeout should be active (old one cleared) + act(() => { + vi.advanceTimersByTime(2000); + }); + + // Should not throw + vi.useRealTimers(); + }); + + it('renders line numbers when showLineNumbers is true', () => { + render( + + ); + expect(screen.getByText('1')).toBeTruthy(); + expect(screen.getByText('2')).toBeTruthy(); + expect(screen.getByText('3')).toBeTruthy(); + }); +}); diff --git a/packages/shared-components/src/components/CodeBlock.tsx b/packages/shared-components/src/components/CodeBlock.tsx index 9a62f5e..ae442a4 100644 --- a/packages/shared-components/src/components/CodeBlock.tsx +++ b/packages/shared-components/src/components/CodeBlock.tsx @@ -1,4 +1,4 @@ -import React, { useState, useCallback, useMemo } from 'react'; +import React, { useState, useCallback, useMemo, useRef, useEffect } from 'react'; import { Copy, Check, Maximize2, Minimize2, Download, WrapText } from 'lucide-react'; import { COLORS, SPACING, RADIUS, TYPOGRAPHY } from '../styles/plugin-styles'; @@ -195,27 +195,40 @@ export function CodeBlock({ const [copied, setCopied] = useState(false); const [expanded, setExpanded] = useState(false); const [wrapText, setWrapText] = useState(wrap); - + const copiedTimeoutRef = useRef | null>(null); + + // Clean up copy timeout on unmount + useEffect(() => { + return () => { + if (copiedTimeoutRef.current) { + clearTimeout(copiedTimeoutRef.current); + } + }; + }, []); + const syntaxTheme = customTheme || THEMES[theme] || THEMES.dark; - + // Split code into lines const lines = useMemo(() => code.split('\n'), [code]); - + // Highlight syntax for each line const highlightedLines = useMemo(() => { return lines.map(line => highlightSyntax(line, language, syntaxTheme)); }, [lines, language, syntaxTheme]); - + // Handle copy const handleCopy = useCallback(() => { navigator.clipboard.writeText(code).catch(() => { // Clipboard write may fail (e.g., permissions denied) }); setCopied(true); - setTimeout(() => setCopied(false), 2000); + if (copiedTimeoutRef.current) { + clearTimeout(copiedTimeoutRef.current); + } + copiedTimeoutRef.current = setTimeout(() => setCopied(false), 2000); if (onCopy) onCopy(code); }, [code, onCopy]); - + // Handle download const handleDownload = useCallback(() => { const blob = new Blob([code], { type: 'text/plain' }); @@ -223,10 +236,13 @@ export function CodeBlock({ const a = document.createElement('a'); a.href = url; a.download = filename || `code.${language}`; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); + try { + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + } finally { + URL.revokeObjectURL(url); + } if (onDownload) onDownload(code, filename); }, [code, filename, language, onDownload]); diff --git a/packages/shared-components/src/components/Progress.test.tsx b/packages/shared-components/src/components/Progress.test.tsx new file mode 100644 index 0000000..a46e0ab --- /dev/null +++ b/packages/shared-components/src/components/Progress.test.tsx @@ -0,0 +1,88 @@ +/** + * @vitest-environment jsdom + */ + +import { describe, it, expect } from 'vitest'; +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { ProgressBar, CircularProgress } from './Progress'; + +describe('ProgressBar', () => { + it('renders basic progress bar', () => { + const { container } = render(); + expect(container.firstChild).toBeTruthy(); + }); + + it('calculates correct percentage for normal values', () => { + const { container } = render(); + const bar = container.querySelector('div[style*="width"]'); + expect(bar).toBeTruthy(); + }); + + it('handles max=0 without division by zero', () => { + // The fix: max > 0 ? ... : 0 prevents NaN from 0/0 + const { container } = render( + + ); + // Should render without crashing + expect(container.firstChild).toBeTruthy(); + }); + + it('displays 0% when max is 0 with showValue', () => { + render(); + // The default valueFormat should return '0%' when max is 0 + expect(screen.getByText('0%')).toBeTruthy(); + }); + + it('clamps percentage between 0 and 100', () => { + // value > max should clamp to 100% + const { container } = render(); + expect(container.firstChild).toBeTruthy(); + }); + + it('handles negative values gracefully', () => { + const { container } = render(); + expect(container.firstChild).toBeTruthy(); + }); + + it('renders label when provided', () => { + render(); + expect(screen.getByText('Loading...')).toBeTruthy(); + }); + + it('renders value text when showValue is true', () => { + render(); + expect(screen.getByText('50%')).toBeTruthy(); + }); +}); + +describe('CircularProgress', () => { + it('renders basic circular progress', () => { + const { container } = render(); + expect(container.querySelector('svg')).toBeTruthy(); + }); + + it('handles max=0 without division by zero', () => { + // The fix: max > 0 ? ... : 0 prevents NaN from 0/0 + const { container } = render( + + ); + // Should render without crashing + expect(container.querySelector('svg')).toBeTruthy(); + }); + + it('displays 0% when max is 0 with showValue', () => { + render(); + expect(screen.getByText('0%')).toBeTruthy(); + }); + + it('renders value text when showValue is true', () => { + render(); + expect(screen.getByText('75%')).toBeTruthy(); + }); + + it('handles indeterminate state', () => { + const { container } = render(); + expect(container.querySelector('svg')).toBeTruthy(); + }); +}); diff --git a/packages/shared-components/src/components/Progress.tsx b/packages/shared-components/src/components/Progress.tsx index b98bdc7..7ae242d 100644 --- a/packages/shared-components/src/components/Progress.tsx +++ b/packages/shared-components/src/components/Progress.tsx @@ -31,7 +31,7 @@ export function ProgressBar({ max = 100, label, showValue = false, - valueFormat = (v, m) => `${Math.round((v / m) * 100)}%`, + valueFormat = (v, m) => m > 0 ? `${Math.round((v / m) * 100)}%` : '0%', size = 'md', variant = 'default', striped = false, @@ -42,7 +42,7 @@ export function ProgressBar({ barClassName, barStyle, }: ProgressBarProps) { - const percentage = Math.min(Math.max((value / max) * 100, 0), 100); + const percentage = max > 0 ? Math.min(Math.max((value / max) * 100, 0), 100) : 0; // Get size styles const getSizeStyles = () => { @@ -200,7 +200,7 @@ export function CircularProgress({ size = 40, strokeWidth = 4, showValue = false, - valueFormat = (v, m) => `${Math.round((v / m) * 100)}%`, + valueFormat = (v, m) => m > 0 ? `${Math.round((v / m) * 100)}%` : '0%', variant = 'default', indeterminate = false, className, @@ -208,7 +208,7 @@ export function CircularProgress({ trackColor = COLORS.background.secondary, color, }: CircularProgressProps) { - const percentage = Math.min(Math.max((value / max) * 100, 0), 100); + const percentage = max > 0 ? Math.min(Math.max((value / max) * 100, 0), 100) : 0; const radius = (size - strokeWidth) / 2; const circumference = radius * 2 * Math.PI; const strokeDashoffset = circumference - (percentage / 100) * circumference; diff --git a/packages/shared-components/src/components/Tabs.test.tsx b/packages/shared-components/src/components/Tabs.test.tsx new file mode 100644 index 0000000..01ebbc2 --- /dev/null +++ b/packages/shared-components/src/components/Tabs.test.tsx @@ -0,0 +1,105 @@ +/** + * @vitest-environment jsdom + */ + +import { describe, it, expect, vi } from 'vitest'; +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { Tabs, type Tab } from './Tabs'; + +const createTabs = (count: number, closable = false): Tab[] => + Array.from({ length: count }, (_, i) => ({ + id: `tab-${i}`, + label: `Tab ${i}`, + content:
Content {i}
, + closable, + })); + +describe('Tabs', () => { + it('renders all tabs', () => { + render(); + expect(screen.getByText('Tab 0')).toBeTruthy(); + expect(screen.getByText('Tab 1')).toBeTruthy(); + expect(screen.getByText('Tab 2')).toBeTruthy(); + }); + + it('defaults to first tab active', () => { + render(); + expect(screen.getByText('Content 0')).toBeTruthy(); + }); + + it('calls onTabChange when a tab is clicked', () => { + const onChange = vi.fn(); + render(); + + fireEvent.click(screen.getByText('Tab 1')); + expect(onChange).toHaveBeenCalledWith('tab-1'); + }); + + it('auto-selects first tab when active tab is removed (the fix)', () => { + // Start with 3 tabs, active = tab-1 + const initialTabs = createTabs(3); + const { rerender } = render( + + ); + + // Verify tab-1 content is shown + expect(screen.getByText('Content 1')).toBeTruthy(); + + // Remove tab-1 from the list + const remainingTabs = [initialTabs[0], initialTabs[2]]; + rerender(); + + // After re-render, internal state should fall back to first available tab + expect(screen.getByText('Content 0')).toBeTruthy(); + }); + + it('does not auto-select when controlled (activeTab prop)', () => { + const tabs = createTabs(3); + const { rerender } = render( + + ); + + // Remove tab-1 — controlled mode: component doesn't override activeTab + const remainingTabs = [tabs[0], tabs[2]]; + rerender(); + + // In controlled mode, the component doesn't auto-select + // No content should render since tab-1 no longer exists + expect(screen.queryByText('Content 0')).toBeNull(); + }); + + it('handles disabled tabs', () => { + const onChange = vi.fn(); + const tabs: Tab[] = [ + { id: 'a', label: 'A', content:
A
}, + { id: 'b', label: 'B', content:
B
, disabled: true }, + ]; + + render(); + fireEvent.click(screen.getByText('B')); + expect(onChange).not.toHaveBeenCalled(); + }); + + it('calls onClose when close button is clicked', () => { + const onClose = vi.fn(); + const tabs = createTabs(2, true); + + render(); + + // The X button is inside the tab button + const closeButtons = screen.getAllByRole('button').filter( + btn => btn.querySelector('svg') !== null && btn.closest('[data-tab-id]') === null + ); + // Click first close button found + if (closeButtons.length > 0) { + fireEvent.click(closeButtons[0]); + expect(onClose).toHaveBeenCalled(); + } + }); + + it('renders with empty tabs array', () => { + const { container } = render(); + expect(container.firstChild).toBeTruthy(); + }); +}); diff --git a/packages/shared-components/src/components/Tabs.tsx b/packages/shared-components/src/components/Tabs.tsx index a38c435..d841661 100644 --- a/packages/shared-components/src/components/Tabs.tsx +++ b/packages/shared-components/src/components/Tabs.tsx @@ -78,6 +78,14 @@ export function Tabs({ const activeTabId = controlledActiveTab !== undefined ? controlledActiveTab : internalActiveTab; const activeTabData = tabs.find(tab => tab.id === activeTabId); + + // Auto-select first tab when active tab no longer exists (e.g., after closing) + useEffect(() => { + if (controlledActiveTab !== undefined) return; + if (tabs.length > 0 && !tabs.some(t => t.id === internalActiveTab)) { + setInternalActiveTab(tabs[0].id); + } + }, [tabs, internalActiveTab, controlledActiveTab]); // Handle tab change const handleTabChange = useCallback((tabId: string) => { diff --git a/packages/shared-components/src/components/Tooltip.tsx b/packages/shared-components/src/components/Tooltip.tsx index 878c194..4a93d79 100644 --- a/packages/shared-components/src/components/Tooltip.tsx +++ b/packages/shared-components/src/components/Tooltip.tsx @@ -255,11 +255,11 @@ export function Tooltip({ useEffect(() => { if (isOpen) { calculatePosition(); - window.addEventListener('scroll', calculatePosition); + window.addEventListener('scroll', calculatePosition, true); window.addEventListener('resize', calculatePosition); - + return () => { - window.removeEventListener('scroll', calculatePosition); + window.removeEventListener('scroll', calculatePosition, true); window.removeEventListener('resize', calculatePosition); }; } diff --git a/packages/shared-components/src/styles/plugin-styles.ts b/packages/shared-components/src/styles/plugin-styles.ts index 8fb0793..1e2f236 100644 --- a/packages/shared-components/src/styles/plugin-styles.ts +++ b/packages/shared-components/src/styles/plugin-styles.ts @@ -864,12 +864,16 @@ export const createSidebarResizer = ( }; const handleMouseUp = () => { + document.body.style.cursor = ''; + document.body.style.userSelect = ''; document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mouseup', handleMouseUp); }; document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mouseup', handleMouseUp); + document.body.style.cursor = 'col-resize'; + document.body.style.userSelect = 'none'; }, }); diff --git a/plugins/auth-permissions-mock/src/components/AuthPermissionsMockPanel.tsx b/plugins/auth-permissions-mock/src/components/AuthPermissionsMockPanel.tsx index a751991..cc4c68a 100644 --- a/plugins/auth-permissions-mock/src/components/AuthPermissionsMockPanel.tsx +++ b/plugins/auth-permissions-mock/src/components/AuthPermissionsMockPanel.tsx @@ -482,7 +482,7 @@ function AuthPermissionsMockPanelInner() { {op.key} {op.value && ( - {op.value.substring(0, 50)}... + {op.value.length > 50 ? `${op.value.substring(0, 50)}...` : op.value} )} diff --git a/plugins/bundle-impact-analyzer/src/components/tabs/TreeShakingTab.tsx b/plugins/bundle-impact-analyzer/src/components/tabs/TreeShakingTab.tsx index f885184..44d3c6e 100644 --- a/plugins/bundle-impact-analyzer/src/components/tabs/TreeShakingTab.tsx +++ b/plugins/bundle-impact-analyzer/src/components/tabs/TreeShakingTab.tsx @@ -55,7 +55,7 @@ export function TreeShakingTab({ state }: TreeShakingTabProps) {
{module.name}
- {module.unusedExports ? + {module.unusedExports && module.exports.length > 0 ? `${((module.unusedExports.length / module.exports.length) * 100).toFixed(0)}% unused` : 'N/A' } diff --git a/plugins/form-state-inspector/src/formStateTracker.test.ts b/plugins/form-state-inspector/src/formStateTracker.test.ts new file mode 100644 index 0000000..c680a22 --- /dev/null +++ b/plugins/form-state-inspector/src/formStateTracker.test.ts @@ -0,0 +1,117 @@ +/** + * @vitest-environment jsdom + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock PerformanceObserver before importing the module +vi.stubGlobal('PerformanceObserver', class { + observe() {} + disconnect() {} +}); + +// We need to test the FormStateRegistry class. +// Since it's instantiated as a singleton, we'll test via the exported functions. +import { formStateRegistry } from './formStateTracker'; + +describe('FormStateRegistry', () => { + beforeEach(() => { + // Clean up between tests + formStateRegistry.destroy(); + }); + + describe('registerForm', () => { + it('creates a form with initial state', () => { + const state = formStateRegistry.registerForm('test-form'); + expect(state.formId).toBe('test-form'); + expect(state.isValid).toBe(true); + expect(state.isDirty).toBe(false); + expect(state.submitCount).toBe(0); + expect(state.performanceMetrics.averageValidationTime).toBe(0); + }); + }); + + describe('updatePerformanceMetrics — running average fix', () => { + it('calculates correct running average for validation times', () => { + formStateRegistry.registerForm('avg-form'); + + // Simulate performance entries by calling the internal method + // Since updatePerformanceMetrics is private, we'll test via the public API + // by directly checking the form state after metric updates. + // We access the private method through any cast for testing purposes. + const registry = formStateRegistry as any; + + registry.updatePerformanceMetrics('avg-form', 10); + let state = formStateRegistry.getFormState('avg-form'); + // After 1 validation of 10ms: average should be 10 + expect(state!.performanceMetrics.averageValidationTime).toBe(10); + + registry.updatePerformanceMetrics('avg-form', 20); + state = formStateRegistry.getFormState('avg-form'); + // After 2 validations of [10, 20]: average should be 15 + expect(state!.performanceMetrics.averageValidationTime).toBe(15); + + registry.updatePerformanceMetrics('avg-form', 30); + state = formStateRegistry.getFormState('avg-form'); + // After 3 validations of [10, 20, 30]: average should be 20 + expect(state!.performanceMetrics.averageValidationTime).toBe(20); + }); + + it('handles single validation correctly', () => { + formStateRegistry.registerForm('single-form'); + const registry = formStateRegistry as any; + + registry.updatePerformanceMetrics('single-form', 42); + const state = formStateRegistry.getFormState('single-form'); + expect(state!.performanceMetrics.averageValidationTime).toBe(42); + expect(state!.performanceMetrics.lastValidationTime).toBe(42); + }); + + it('handles zero validation time correctly', () => { + formStateRegistry.registerForm('zero-form'); + const registry = formStateRegistry as any; + + registry.updatePerformanceMetrics('zero-form', 0); + const state = formStateRegistry.getFormState('zero-form'); + expect(state!.performanceMetrics.averageValidationTime).toBe(0); + }); + + it('ignores updates for nonexistent forms', () => { + const registry = formStateRegistry as any; + // Should not throw + registry.updatePerformanceMetrics('nonexistent', 100); + }); + }); + + describe('unregisterForm', () => { + it('removes form and cleans up validation counts', () => { + formStateRegistry.registerForm('cleanup-form'); + const registry = formStateRegistry as any; + registry.updatePerformanceMetrics('cleanup-form', 50); + + formStateRegistry.unregisterForm('cleanup-form'); + + // Form should be gone + expect(formStateRegistry.getFormState('cleanup-form')).toBeUndefined(); + + // Re-register and validate the average starts fresh + formStateRegistry.registerForm('cleanup-form'); + registry.updatePerformanceMetrics('cleanup-form', 100); + const state = formStateRegistry.getFormState('cleanup-form'); + // Should be 100 (not blended with old 50) + expect(state!.performanceMetrics.averageValidationTime).toBe(100); + }); + }); + + describe('destroy', () => { + it('cleans up all forms and state', () => { + formStateRegistry.registerForm('form-1'); + formStateRegistry.registerForm('form-2'); + + formStateRegistry.destroy(); + + expect(formStateRegistry.getFormState('form-1')).toBeUndefined(); + expect(formStateRegistry.getFormState('form-2')).toBeUndefined(); + }); + }); +}); diff --git a/plugins/form-state-inspector/src/formStateTracker.ts b/plugins/form-state-inspector/src/formStateTracker.ts index 28ba0bc..a1e2cda 100644 --- a/plugins/form-state-inspector/src/formStateTracker.ts +++ b/plugins/form-state-inspector/src/formStateTracker.ts @@ -14,6 +14,7 @@ import { formStateEventClient } from './formEventClient'; // Global registry for tracked forms class FormStateRegistry { private forms: Map = new Map(); + private validationCounts: Map = new Map(); private performanceObserver: PerformanceObserver | null = null; private fieldObservers: Map = new Map(); private submitHandlers: Map void }> = new Map(); @@ -87,7 +88,10 @@ class FormStateRegistry { const metrics = form.performanceMetrics; metrics.lastValidationTime = validationTime; - metrics.averageValidationTime = (metrics.averageValidationTime + validationTime) / 2; + const prevCount = this.validationCounts.get(formId) || 0; + const newCount = prevCount + 1; + this.validationCounts.set(formId, newCount); + metrics.averageValidationTime = (metrics.averageValidationTime * prevCount + validationTime) / newCount; formStateEventClient.emit('performance-update', { formId, metrics }); this.broadcastFormsState(); @@ -555,6 +559,7 @@ class FormStateRegistry { } this.forms.delete(formId); + this.validationCounts.delete(formId); this.broadcastFormsState(); } @@ -576,6 +581,7 @@ class FormStateRegistry { this.submitHandlers.clear(); this.forms.clear(); + this.validationCounts.clear(); } } diff --git a/plugins/i18n-devtools/src/components/BundleAnalyzer.tsx b/plugins/i18n-devtools/src/components/BundleAnalyzer.tsx index 5f5de66..baadf3a 100644 --- a/plugins/i18n-devtools/src/components/BundleAnalyzer.tsx +++ b/plugins/i18n-devtools/src/components/BundleAnalyzer.tsx @@ -104,7 +104,7 @@ export function BundleAnalyzer({ // Process and sort data const processedData = useMemo(() => { - return analysisData.sort((a, b) => { + return [...analysisData].sort((a, b) => { switch (sortBy) { case 'size': return b.size - a.size; diff --git a/plugins/i18n-devtools/src/components/CoverageVisualization.tsx b/plugins/i18n-devtools/src/components/CoverageVisualization.tsx index 631f6e5..8515c7e 100644 --- a/plugins/i18n-devtools/src/components/CoverageVisualization.tsx +++ b/plugins/i18n-devtools/src/components/CoverageVisualization.tsx @@ -39,7 +39,8 @@ export function CoverageVisualization({ const completeLanguages = languages.filter(lang => lang.completeness >= 95).length; const completeNamespaces = namespaces.filter(ns => { - const avgCoverage = Object.values(ns.translationCoverage).reduce((sum, cov) => sum + cov, 0) / Object.values(ns.translationCoverage).length; + const coverageValues = Object.values(ns.translationCoverage); + const avgCoverage = coverageValues.length > 0 ? coverageValues.reduce((sum, cov) => sum + cov, 0) / coverageValues.length : 0; return avgCoverage >= 95; }).length; @@ -263,14 +264,17 @@ export function CoverageVisualization({
- {namespaces + {[...namespaces] .sort((a, b) => { - const aAvg = Object.values(a.translationCoverage).reduce((sum, cov) => sum + cov, 0) / Object.values(a.translationCoverage).length; - const bAvg = Object.values(b.translationCoverage).reduce((sum, cov) => sum + cov, 0) / Object.values(b.translationCoverage).length; + const aVals = Object.values(a.translationCoverage); + const bVals = Object.values(b.translationCoverage); + const aAvg = aVals.length > 0 ? aVals.reduce((sum, cov) => sum + cov, 0) / aVals.length : 0; + const bAvg = bVals.length > 0 ? bVals.reduce((sum, cov) => sum + cov, 0) / bVals.length : 0; return bAvg - aAvg; }) .map(ns => { - const averageCoverage = Object.values(ns.translationCoverage).reduce((sum, cov) => sum + cov, 0) / Object.values(ns.translationCoverage).length; + const coverageValues = Object.values(ns.translationCoverage); + const averageCoverage = coverageValues.length > 0 ? coverageValues.reduce((sum, cov) => sum + cov, 0) / coverageValues.length : 0; return (
@@ -421,7 +425,8 @@ export function CoverageVisualization({ {namespaces.map(ns => { - const averageCoverage = Object.values(ns.translationCoverage).reduce((sum, cov) => sum + cov, 0) / Object.values(ns.translationCoverage).length; + const coverageValues = Object.values(ns.translationCoverage); + const averageCoverage = coverageValues.length > 0 ? coverageValues.reduce((sum, cov) => sum + cov, 0) / coverageValues.length : 0; return ( { - if (isSupported && !isRunning) { + if (isSupported) { start(); } - }, [isSupported, isRunning, start]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isSupported]); if (!isSupported) { return ( diff --git a/plugins/memory-performance-profiler/src/components/MemoryTimeline.test.tsx b/plugins/memory-performance-profiler/src/components/MemoryTimeline.test.tsx new file mode 100644 index 0000000..be729e7 --- /dev/null +++ b/plugins/memory-performance-profiler/src/components/MemoryTimeline.test.tsx @@ -0,0 +1,103 @@ +/** + * @vitest-environment jsdom + */ + +import { describe, it, expect } from 'vitest'; +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { MemoryTimeline } from './MemoryTimeline'; +import type { MemoryTimelinePoint } from '../types'; + +function makePoint(usedMemory: number, timestamp = Date.now()): MemoryTimelinePoint { + return { + timestamp, + usedMemory, + totalMemory: usedMemory * 2, + }; +} + +describe('MemoryTimeline', () => { + it('renders empty state when no data', () => { + render(); + expect(screen.getByText('No timeline data available')).toBeTruthy(); + }); + + it('renders single data point without division by zero (the fix)', () => { + // With 1 point, index=0, length=1 → 0/(1-1) = 0/0 = NaN + // Fix: timeline.length <= 1 ? 50 : (index / (timeline.length - 1)) * 100 + const timeline = [makePoint(1024 * 1024)]; + const { container } = render( + + ); + // Should render chart with data point, not crash + expect(container.querySelector('svg')).toBeTruthy(); + expect(container.querySelector('circle')).toBeTruthy(); + }); + + it('renders multiple data points correctly', () => { + const timeline = [ + makePoint(1024 * 1024, 1000), + makePoint(2 * 1024 * 1024, 2000), + makePoint(1.5 * 1024 * 1024, 3000), + ]; + const { container } = render( + + ); + expect(container.querySelector('svg')).toBeTruthy(); + // Should have 3 data point circles + const circles = container.querySelectorAll('circle[r="3"]'); + expect(circles.length).toBe(3); + }); + + it('shows summary stats for non-empty timeline', () => { + const timeline = [ + makePoint(1024 * 1024, 1000), + makePoint(2 * 1024 * 1024, 2000), + ]; + render(); + expect(screen.getByText('Current')).toBeTruthy(); + expect(screen.getByText('Peak')).toBeTruthy(); + expect(screen.getByText('Low')).toBeTruthy(); + expect(screen.getByText('Range')).toBeTruthy(); + }); + + it('detects upward memory trend', () => { + const timeline = [ + makePoint(1024 * 1024, 1000), + makePoint(2 * 1024 * 1024, 2000), + makePoint(3 * 1024 * 1024, 3000), + ]; + const { container } = render( + + ); + // Trend is 'up' — should render without error + expect(container.firstChild).toBeTruthy(); + }); + + it('handles equal memory values (range=0)', () => { + const timeline = [ + makePoint(1024 * 1024, 1000), + makePoint(1024 * 1024, 2000), + ]; + // range = 0, so y = 50 (fallback) + const { container } = render( + + ); + expect(container.querySelector('svg')).toBeTruthy(); + }); + + it('renders GC events when present', () => { + const timeline: MemoryTimelinePoint[] = [ + { + timestamp: 1000, + usedMemory: 2 * 1024 * 1024, + totalMemory: 4 * 1024 * 1024, + gcEvent: { type: 'major', memoryFreed: 512 * 1024, duration: 10, timestamp: 1000 }, + }, + ]; + const { container } = render( + + ); + expect(container.querySelector('svg')).toBeTruthy(); + }); +}); diff --git a/plugins/memory-performance-profiler/src/components/MemoryTimeline.tsx b/plugins/memory-performance-profiler/src/components/MemoryTimeline.tsx index d9cbafa..cf01135 100644 --- a/plugins/memory-performance-profiler/src/components/MemoryTimeline.tsx +++ b/plugins/memory-performance-profiler/src/components/MemoryTimeline.tsx @@ -21,7 +21,7 @@ export function MemoryTimeline({ timeline, warnings: _warnings, className }: Mem const chartData = timeline.map((point, index) => ({ ...point, - x: (index / (timeline.length - 1)) * 100, + x: timeline.length <= 1 ? 50 : (index / (timeline.length - 1)) * 100, y: range > 0 ? ((maxMem - point.usedMemory) / range) * 80 + 10 : 50 })); diff --git a/plugins/memory-performance-profiler/src/components/ProfilingSettings.tsx b/plugins/memory-performance-profiler/src/components/ProfilingSettings.tsx index 3d0a3cd..c6e020a 100644 --- a/plugins/memory-performance-profiler/src/components/ProfilingSettings.tsx +++ b/plugins/memory-performance-profiler/src/components/ProfilingSettings.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; import { clsx } from 'clsx'; import { Settings, @@ -29,6 +29,11 @@ export function ProfilingSettings({ const [localConfig, setLocalConfig] = useState(config); const [hasChanges, setHasChanges] = useState(false); + useEffect(() => { + setLocalConfig(config); + setHasChanges(false); + }, [config]); + const updateLocalConfig = (updates: Partial) => { const newConfig = { ...localConfig, ...updates }; setLocalConfig(newConfig); @@ -96,7 +101,7 @@ export function ProfilingSettings({ max="10000" step="100" value={localConfig.samplingInterval} - onChange={(e) => updateLocalConfig({ samplingInterval: parseInt(e.target.value) })} + onChange={(e) => { const v = parseInt(e.target.value); if (!isNaN(v)) updateLocalConfig({ samplingInterval: v }); }} className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" />

@@ -114,7 +119,7 @@ export function ProfilingSettings({ max="1000" step="10" value={localConfig.maxSnapshots} - onChange={(e) => updateLocalConfig({ maxSnapshots: parseInt(e.target.value) })} + onChange={(e) => { const v = parseInt(e.target.value); if (!isNaN(v)) updateLocalConfig({ maxSnapshots: v }); }} className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" />

@@ -233,12 +238,12 @@ export function ProfilingSettings({ min="10" max="1000" value={Math.round(localConfig.memoryBudget.total / 1024 / 1024)} - onChange={(e) => updateLocalConfig({ + onChange={(e) => { const v = parseInt(e.target.value); if (!isNaN(v)) updateLocalConfig({ memoryBudget: { ...localConfig.memoryBudget!, - total: parseInt(e.target.value) * 1024 * 1024 + total: v * 1024 * 1024 } - })} + }); }} className="w-full px-3 py-2 pr-12 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" /> @@ -257,12 +262,12 @@ export function ProfilingSettings({ min="10" max="1000" value={Math.round(localConfig.memoryBudget.warning / 1024 / 1024)} - onChange={(e) => updateLocalConfig({ + onChange={(e) => { const v = parseInt(e.target.value); if (!isNaN(v)) updateLocalConfig({ memoryBudget: { ...localConfig.memoryBudget!, - warning: parseInt(e.target.value) * 1024 * 1024 + warning: v * 1024 * 1024 } - })} + }); }} className="w-full px-3 py-2 pr-12 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" /> @@ -281,12 +286,12 @@ export function ProfilingSettings({ min="10" max="1000" value={Math.round(localConfig.memoryBudget.critical / 1024 / 1024)} - onChange={(e) => updateLocalConfig({ + onChange={(e) => { const v = parseInt(e.target.value); if (!isNaN(v)) updateLocalConfig({ memoryBudget: { ...localConfig.memoryBudget!, - critical: parseInt(e.target.value) * 1024 * 1024 + critical: v * 1024 * 1024 } - })} + }); }} className="w-full px-3 py-2 pr-12 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" /> diff --git a/plugins/render-waste-detector/src/components/PluginPanel.tsx b/plugins/render-waste-detector/src/components/PluginPanel.tsx index b50b5d1..8d0339a 100644 --- a/plugins/render-waste-detector/src/components/PluginPanel.tsx +++ b/plugins/render-waste-detector/src/components/PluginPanel.tsx @@ -1,4 +1,4 @@ -import React, { useMemo } from "react"; +import React, { useEffect, useMemo } from "react"; import { useSyncExternalStore } from "use-sync-external-store/shim"; import { Activity, @@ -52,18 +52,17 @@ function PluginPanelInner({ children, }: RenderWasteDetectorPanelProps) { // Create or get event client - const eventClient = (() => { - const client = - getRenderWasteDetectorDevToolsClient() || - createRenderWasteDetectorDevToolsClient(); + const eventClient = useMemo(() => { + return getRenderWasteDetectorDevToolsClient() || createRenderWasteDetectorDevToolsClient(); + }, []); - // Apply default settings if provided + // Apply default settings on mount only + useEffect(() => { if (defaultSettings) { - client.updateSettings(defaultSettings); + eventClient.updateSettings(defaultSettings); } - - return client; - })(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); // Subscribe to state changes const state = useSyncExternalStore( diff --git a/plugins/render-waste-detector/src/components/tabs/SettingsTab.tsx b/plugins/render-waste-detector/src/components/tabs/SettingsTab.tsx index 29263ec..a535eaa 100644 --- a/plugins/render-waste-detector/src/components/tabs/SettingsTab.tsx +++ b/plugins/render-waste-detector/src/components/tabs/SettingsTab.tsx @@ -140,12 +140,7 @@ export function SettingsTab({ min="1" max="100" value={settings.minRenderThreshold} - onChange={(e) => - handleSettingChange( - "minRenderThreshold", - parseInt(e.target.value), - ) - } + onChange={(e) => { const v = parseInt(e.target.value); if (!isNaN(v)) handleSettingChange("minRenderThreshold", v); }} />

@@ -162,12 +157,7 @@ export function SettingsTab({ max="3600000" step="1000" value={settings.maxRecordingTime} - onChange={(e) => - handleSettingChange( - "maxRecordingTime", - parseInt(e.target.value), - ) - } + onChange={(e) => { const v = parseInt(e.target.value); if (!isNaN(v)) handleSettingChange("maxRecordingTime", v); }} />

@@ -185,7 +175,7 @@ export function SettingsTab({ step="100" value={settings.maxEvents} onChange={(e) => - handleSettingChange("maxEvents", parseInt(e.target.value)) + { const v = parseInt(e.target.value); if (!isNaN(v)) handleSettingChange("maxEvents", v); } } /> @@ -204,7 +194,7 @@ export function SettingsTab({ step="10" value={settings.debounceMs} onChange={(e) => - handleSettingChange("debounceMs", parseInt(e.target.value)) + { const v = parseInt(e.target.value); if (!isNaN(v)) handleSettingChange("debounceMs", v); } } /> diff --git a/plugins/render-waste-detector/src/components/tabs/TimelineTab.tsx b/plugins/render-waste-detector/src/components/tabs/TimelineTab.tsx index b00fdb2..c6fa9b7 100644 --- a/plugins/render-waste-detector/src/components/tabs/TimelineTab.tsx +++ b/plugins/render-waste-detector/src/components/tabs/TimelineTab.tsx @@ -76,7 +76,7 @@ export function TimelineTab({ key={event.id} className={`timeline-event ${event.reason}`} style={{ - left: `${(event.timestamp - renderEvents[0]?.timestamp || 0) / 10}px`, + left: `${(event.timestamp - (renderEvents[0]?.timestamp ?? 0)) / 10}px`, width: `${Math.max(2, event.duration / 2)}px`, }} title={`${event.componentName}: ${event.reason} (${event.duration.toFixed(1)}ms)`}