Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 93 additions & 0 deletions packages/shared-components/src/components/CodeBlock.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<CodeBlock code="const x = 1;" language="javascript" />);
expect(screen.getByText(/const/)).toBeTruthy();
});

it('shows copy button when copyable', () => {
const { container } = render(
<CodeBlock code="hello" copyable />
);
// 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(
<CodeBlock code="test code" copyable />
);

// 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(
<CodeBlock code="test" copyable />
);

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(
<CodeBlock
code={"line1\nline2\nline3"}
showLineNumbers
startLineNumber={1}
/>
);
expect(screen.getByText('1')).toBeTruthy();
expect(screen.getByText('2')).toBeTruthy();
expect(screen.getByText('3')).toBeTruthy();
});
});
38 changes: 27 additions & 11 deletions packages/shared-components/src/components/CodeBlock.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -195,38 +195,54 @@ export function CodeBlock({
const [copied, setCopied] = useState(false);
const [expanded, setExpanded] = useState(false);
const [wrapText, setWrapText] = useState(wrap);

const copiedTimeoutRef = useRef<ReturnType<typeof setTimeout> | 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' });
const url = URL.createObjectURL(blob);
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]);

Expand Down
88 changes: 88 additions & 0 deletions packages/shared-components/src/components/Progress.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<ProgressBar value={50} />);
expect(container.firstChild).toBeTruthy();
});

it('calculates correct percentage for normal values', () => {
const { container } = render(<ProgressBar value={75} max={100} />);
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(
<ProgressBar value={50} max={0} showValue />
);
// Should render without crashing
expect(container.firstChild).toBeTruthy();
});

it('displays 0% when max is 0 with showValue', () => {
render(<ProgressBar value={50} max={0} showValue />);
// 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(<ProgressBar value={200} max={100} />);
expect(container.firstChild).toBeTruthy();
});

it('handles negative values gracefully', () => {
const { container } = render(<ProgressBar value={-10} max={100} />);
expect(container.firstChild).toBeTruthy();
});

it('renders label when provided', () => {
render(<ProgressBar value={50} label="Loading..." />);
expect(screen.getByText('Loading...')).toBeTruthy();
});

it('renders value text when showValue is true', () => {
render(<ProgressBar value={50} max={100} showValue />);
expect(screen.getByText('50%')).toBeTruthy();
});
});

describe('CircularProgress', () => {
it('renders basic circular progress', () => {
const { container } = render(<CircularProgress value={50} />);
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(
<CircularProgress value={50} max={0} showValue />
);
// Should render without crashing
expect(container.querySelector('svg')).toBeTruthy();
});

it('displays 0% when max is 0 with showValue', () => {
render(<CircularProgress value={50} max={0} showValue />);
expect(screen.getByText('0%')).toBeTruthy();
});

it('renders value text when showValue is true', () => {
render(<CircularProgress value={75} max={100} showValue />);
expect(screen.getByText('75%')).toBeTruthy();
});

it('handles indeterminate state', () => {
const { container } = render(<CircularProgress indeterminate />);
expect(container.querySelector('svg')).toBeTruthy();
});
});
8 changes: 4 additions & 4 deletions packages/shared-components/src/components/Progress.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 = () => {
Expand Down Expand Up @@ -200,15 +200,15 @@ 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,
style,
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;
Expand Down
105 changes: 105 additions & 0 deletions packages/shared-components/src/components/Tabs.test.tsx
Original file line number Diff line number Diff line change
@@ -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: <div>Content {i}</div>,
closable,
}));

describe('Tabs', () => {
it('renders all tabs', () => {
render(<Tabs tabs={createTabs(3)} />);
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(<Tabs tabs={createTabs(3)} />);
expect(screen.getByText('Content 0')).toBeTruthy();
});

it('calls onTabChange when a tab is clicked', () => {
const onChange = vi.fn();
render(<Tabs tabs={createTabs(3)} onTabChange={onChange} />);

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(
<Tabs tabs={initialTabs} defaultActiveTab="tab-1" />
);

// 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(<Tabs tabs={remainingTabs} />);

// 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(
<Tabs tabs={tabs} activeTab="tab-1" />
);

// Remove tab-1 — controlled mode: component doesn't override activeTab
const remainingTabs = [tabs[0], tabs[2]];
rerender(<Tabs tabs={remainingTabs} activeTab="tab-1" />);

// 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: <div>A</div> },
{ id: 'b', label: 'B', content: <div>B</div>, disabled: true },
];

render(<Tabs tabs={tabs} onTabChange={onChange} />);
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(<Tabs tabs={tabs} onClose={onClose} />);

// 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(<Tabs tabs={[]} />);
expect(container.firstChild).toBeTruthy();
});
});
Loading
Loading