mirror of
https://github.com/jlengrand/Maestro.git
synced 2026-03-10 08:31:19 +00:00
Changed from centering the tab to using scrollIntoView with 'nearest' option. This ensures the entire tab including the close button is visible, rather than potentially cutting off the right edge when near container boundaries.
5507 lines
144 KiB
TypeScript
5507 lines
144 KiB
TypeScript
import React from 'react';
|
|
import { render, screen, fireEvent, act, waitFor } from '@testing-library/react';
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
import { TabBar } from '../../../renderer/components/TabBar';
|
|
import type { AITab, Theme, FilePreviewTab } from '../../../renderer/types';
|
|
|
|
// Mock lucide-react icons
|
|
vi.mock('lucide-react', () => ({
|
|
X: ({ className, style }: { className?: string; style?: React.CSSProperties }) => (
|
|
<span data-testid="x-icon" className={className} style={style}>
|
|
X
|
|
</span>
|
|
),
|
|
Plus: ({ className, style }: { className?: string; style?: React.CSSProperties }) => (
|
|
<span data-testid="plus-icon" className={className} style={style}>
|
|
+
|
|
</span>
|
|
),
|
|
Star: ({ className, style }: { className?: string; style?: React.CSSProperties }) => (
|
|
<span data-testid="star-icon" className={className} style={style}>
|
|
★
|
|
</span>
|
|
),
|
|
Copy: ({ className, style }: { className?: string; style?: React.CSSProperties }) => (
|
|
<span data-testid="copy-icon" className={className} style={style}>
|
|
📋
|
|
</span>
|
|
),
|
|
Edit2: ({ className, style }: { className?: string; style?: React.CSSProperties }) => (
|
|
<span data-testid="edit-icon" className={className} style={style}>
|
|
✎
|
|
</span>
|
|
),
|
|
Mail: ({ className, style }: { className?: string; style?: React.CSSProperties }) => (
|
|
<span data-testid="mail-icon" className={className} style={style}>
|
|
✉
|
|
</span>
|
|
),
|
|
Pencil: ({ className, style }: { className?: string; style?: React.CSSProperties }) => (
|
|
<span data-testid="pencil-icon" className={className} style={style}>
|
|
✏
|
|
</span>
|
|
),
|
|
Search: ({ className, style }: { className?: string; style?: React.CSSProperties }) => (
|
|
<span data-testid="search-icon" className={className} style={style}>
|
|
🔍
|
|
</span>
|
|
),
|
|
GitMerge: ({ className, style }: { className?: string; style?: React.CSSProperties }) => (
|
|
<span data-testid="git-merge-icon" className={className} style={style}>
|
|
⎇
|
|
</span>
|
|
),
|
|
ArrowRightCircle: ({ className, style }: { className?: string; style?: React.CSSProperties }) => (
|
|
<span data-testid="arrow-right-circle-icon" className={className} style={style}>
|
|
→
|
|
</span>
|
|
),
|
|
Minimize2: ({ className, style }: { className?: string; style?: React.CSSProperties }) => (
|
|
<span data-testid="minimize-icon" className={className} style={style}>
|
|
⊟
|
|
</span>
|
|
),
|
|
Download: ({ className, style }: { className?: string; style?: React.CSSProperties }) => (
|
|
<span data-testid="download-icon" className={className} style={style}>
|
|
↓
|
|
</span>
|
|
),
|
|
Clipboard: ({ className, style }: { className?: string; style?: React.CSSProperties }) => (
|
|
<span data-testid="clipboard-icon" className={className} style={style}>
|
|
📎
|
|
</span>
|
|
),
|
|
Share2: ({ className, style }: { className?: string; style?: React.CSSProperties }) => (
|
|
<span data-testid="share2-icon" className={className} style={style}>
|
|
⬆
|
|
</span>
|
|
),
|
|
ChevronsLeft: ({ className, style }: { className?: string; style?: React.CSSProperties }) => (
|
|
<span data-testid="chevrons-left-icon" className={className} style={style}>
|
|
«
|
|
</span>
|
|
),
|
|
ChevronsRight: ({ className, style }: { className?: string; style?: React.CSSProperties }) => (
|
|
<span data-testid="chevrons-right-icon" className={className} style={style}>
|
|
»
|
|
</span>
|
|
),
|
|
ExternalLink: ({ className, style }: { className?: string; style?: React.CSSProperties }) => (
|
|
<span data-testid="external-link-icon" className={className} style={style}>
|
|
↗
|
|
</span>
|
|
),
|
|
FolderOpen: ({ className, style }: { className?: string; style?: React.CSSProperties }) => (
|
|
<span data-testid="folder-open-icon" className={className} style={style}>
|
|
📂
|
|
</span>
|
|
),
|
|
FileText: ({ className, style }: { className?: string; style?: React.CSSProperties }) => (
|
|
<span data-testid="file-text-icon" className={className} style={style}>
|
|
📄
|
|
</span>
|
|
),
|
|
}));
|
|
|
|
// Mock react-dom createPortal
|
|
vi.mock('react-dom', async () => {
|
|
const actual = await vi.importActual('react-dom');
|
|
return {
|
|
...actual,
|
|
createPortal: (children: React.ReactNode) => children,
|
|
};
|
|
});
|
|
|
|
// Test theme
|
|
const mockTheme: Theme = {
|
|
id: 'test-theme',
|
|
name: 'Test Theme',
|
|
mode: 'dark',
|
|
colors: {
|
|
bgMain: '#1a1a1a',
|
|
bgSidebar: '#2a2a2a',
|
|
bgActivity: '#3a3a3a',
|
|
textMain: '#ffffff',
|
|
textDim: '#888888',
|
|
accent: '#007acc',
|
|
border: '#444444',
|
|
error: '#ff4444',
|
|
success: '#44ff44',
|
|
warning: '#ffaa00',
|
|
vibe: '#ff00ff',
|
|
agentStatus: '#00ff00',
|
|
},
|
|
};
|
|
|
|
// Helper to create tabs
|
|
function createTab(overrides: Partial<AITab> = {}): AITab {
|
|
return {
|
|
id: 'tab-1',
|
|
agentSessionId: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
|
|
state: 'idle',
|
|
name: '',
|
|
starred: false,
|
|
hasUnread: false,
|
|
inputValue: '',
|
|
stagedImages: [],
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe('TabBar', () => {
|
|
const mockOnTabSelect = vi.fn();
|
|
const mockOnTabClose = vi.fn();
|
|
const mockOnNewTab = vi.fn();
|
|
const mockOnTabRename = vi.fn();
|
|
const mockOnRequestRename = vi.fn();
|
|
const mockOnTabReorder = vi.fn();
|
|
const mockOnTabStar = vi.fn();
|
|
const mockOnTabMarkUnread = vi.fn();
|
|
const mockOnToggleUnreadFilter = vi.fn();
|
|
const mockOnOpenTabSearch = vi.fn();
|
|
|
|
// Mock timers for hover delays
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
vi.clearAllMocks();
|
|
// Mock scrollTo and scrollIntoView
|
|
Element.prototype.scrollTo = vi.fn();
|
|
Element.prototype.scrollIntoView = vi.fn();
|
|
// Mock clipboard
|
|
Object.assign(navigator, {
|
|
clipboard: {
|
|
writeText: vi.fn().mockResolvedValue(undefined),
|
|
},
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
describe('rendering', () => {
|
|
it('renders tabs correctly', () => {
|
|
const tabs = [createTab({ id: 'tab-1', name: 'Tab 1' })];
|
|
|
|
render(
|
|
<TabBar
|
|
tabs={tabs}
|
|
activeTabId="tab-1"
|
|
theme={mockTheme}
|
|
onTabSelect={mockOnTabSelect}
|
|
onTabClose={mockOnTabClose}
|
|
onNewTab={mockOnNewTab}
|
|
/>
|
|
);
|
|
|
|
expect(screen.getByText('Tab 1')).toBeInTheDocument();
|
|
});
|
|
|
|
it('renders new tab button', () => {
|
|
render(
|
|
<TabBar
|
|
tabs={[createTab()]}
|
|
activeTabId="tab-1"
|
|
theme={mockTheme}
|
|
onTabSelect={mockOnTabSelect}
|
|
onTabClose={mockOnTabClose}
|
|
onNewTab={mockOnNewTab}
|
|
/>
|
|
);
|
|
|
|
expect(screen.getByTitle('New tab (Cmd+T)')).toBeInTheDocument();
|
|
});
|
|
|
|
it('renders unread filter button', () => {
|
|
render(
|
|
<TabBar
|
|
tabs={[createTab()]}
|
|
activeTabId="tab-1"
|
|
theme={mockTheme}
|
|
onTabSelect={mockOnTabSelect}
|
|
onTabClose={mockOnTabClose}
|
|
onNewTab={mockOnNewTab}
|
|
/>
|
|
);
|
|
|
|
expect(screen.getByTitle(/Filter unread tabs/)).toBeInTheDocument();
|
|
});
|
|
|
|
it('renders tab search button when onOpenTabSearch provided', () => {
|
|
render(
|
|
<TabBar
|
|
tabs={[createTab()]}
|
|
activeTabId="tab-1"
|
|
theme={mockTheme}
|
|
onTabSelect={mockOnTabSelect}
|
|
onTabClose={mockOnTabClose}
|
|
onNewTab={mockOnNewTab}
|
|
onOpenTabSearch={mockOnOpenTabSearch}
|
|
/>
|
|
);
|
|
|
|
expect(screen.getByTitle('Search tabs (Cmd+Shift+O)')).toBeInTheDocument();
|
|
});
|
|
|
|
it('does not render tab search button when onOpenTabSearch not provided', () => {
|
|
render(
|
|
<TabBar
|
|
tabs={[createTab()]}
|
|
activeTabId="tab-1"
|
|
theme={mockTheme}
|
|
onTabSelect={mockOnTabSelect}
|
|
onTabClose={mockOnTabClose}
|
|
onNewTab={mockOnNewTab}
|
|
/>
|
|
);
|
|
|
|
expect(screen.queryByTitle('Search tabs (Cmd+Shift+O)')).not.toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe('getTabDisplayName', () => {
|
|
it('displays tab name when provided', () => {
|
|
const tabs = [createTab({ id: 'tab-1', name: 'My Custom Tab' })];
|
|
|
|
render(
|
|
<TabBar
|
|
tabs={tabs}
|
|
activeTabId="tab-1"
|
|
theme={mockTheme}
|
|
onTabSelect={mockOnTabSelect}
|
|
onTabClose={mockOnTabClose}
|
|
onNewTab={mockOnNewTab}
|
|
/>
|
|
);
|
|
|
|
expect(screen.getByText('My Custom Tab')).toBeInTheDocument();
|
|
});
|
|
|
|
it('displays first UUID octet when no name but agentSessionId exists', () => {
|
|
const tabs = [
|
|
createTab({
|
|
id: 'tab-1',
|
|
name: '',
|
|
agentSessionId: 'abcd1234-5678-9abc-def0-123456789012',
|
|
}),
|
|
];
|
|
|
|
render(
|
|
<TabBar
|
|
tabs={tabs}
|
|
activeTabId="tab-1"
|
|
theme={mockTheme}
|
|
onTabSelect={mockOnTabSelect}
|
|
onTabClose={mockOnTabClose}
|
|
onNewTab={mockOnNewTab}
|
|
/>
|
|
);
|
|
|
|
expect(screen.getByText('ABCD1234')).toBeInTheDocument();
|
|
});
|
|
|
|
it('displays "New Session" when no name and no agentSessionId', () => {
|
|
const tabs = [
|
|
createTab({
|
|
id: 'tab-1',
|
|
name: '',
|
|
agentSessionId: undefined,
|
|
}),
|
|
];
|
|
|
|
render(
|
|
<TabBar
|
|
tabs={tabs}
|
|
activeTabId="tab-1"
|
|
theme={mockTheme}
|
|
onTabSelect={mockOnTabSelect}
|
|
onTabClose={mockOnTabClose}
|
|
onNewTab={mockOnNewTab}
|
|
/>
|
|
);
|
|
|
|
expect(screen.getByText('New Session')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe('tab selection', () => {
|
|
it('calls onTabSelect when tab is clicked', () => {
|
|
const tabs = [
|
|
createTab({ id: 'tab-1', name: 'Tab 1' }),
|
|
createTab({ id: 'tab-2', name: 'Tab 2' }),
|
|
];
|
|
|
|
render(
|
|
<TabBar
|
|
tabs={tabs}
|
|
activeTabId="tab-1"
|
|
theme={mockTheme}
|
|
onTabSelect={mockOnTabSelect}
|
|
onTabClose={mockOnTabClose}
|
|
onNewTab={mockOnNewTab}
|
|
/>
|
|
);
|
|
|
|
fireEvent.click(screen.getByText('Tab 2'));
|
|
expect(mockOnTabSelect).toHaveBeenCalledWith('tab-2');
|
|
});
|
|
|
|
it('applies active styles to active tab', () => {
|
|
const tabs = [
|
|
createTab({ id: 'tab-1', name: 'Tab 1' }),
|
|
createTab({ id: 'tab-2', name: 'Tab 2' }),
|
|
];
|
|
|
|
render(
|
|
<TabBar
|
|
tabs={tabs}
|
|
activeTabId="tab-1"
|
|
theme={mockTheme}
|
|
onTabSelect={mockOnTabSelect}
|
|
onTabClose={mockOnTabClose}
|
|
onNewTab={mockOnNewTab}
|
|
/>
|
|
);
|
|
|
|
const activeTab = screen.getByText('Tab 1').closest('[data-tab-id]');
|
|
expect(activeTab).toHaveStyle({ backgroundColor: mockTheme.colors.bgMain });
|
|
});
|
|
});
|
|
|
|
describe('tab close', () => {
|
|
it('calls onTabClose when close button is clicked', () => {
|
|
const tabs = [createTab({ id: 'tab-1', name: 'Tab 1' })];
|
|
|
|
render(
|
|
<TabBar
|
|
tabs={tabs}
|
|
activeTabId="tab-1"
|
|
theme={mockTheme}
|
|
onTabSelect={mockOnTabSelect}
|
|
onTabClose={mockOnTabClose}
|
|
onNewTab={mockOnNewTab}
|
|
/>
|
|
);
|
|
|
|
const closeButton = screen.getByTitle('Close tab');
|
|
fireEvent.click(closeButton);
|
|
expect(mockOnTabClose).toHaveBeenCalledWith('tab-1');
|
|
});
|
|
|
|
it('calls onTabClose on middle-click', () => {
|
|
const tabs = [createTab({ id: 'tab-1', name: 'Tab 1' })];
|
|
|
|
render(
|
|
<TabBar
|
|
tabs={tabs}
|
|
activeTabId="tab-1"
|
|
theme={mockTheme}
|
|
onTabSelect={mockOnTabSelect}
|
|
onTabClose={mockOnTabClose}
|
|
onNewTab={mockOnNewTab}
|
|
/>
|
|
);
|
|
|
|
const tab = screen.getByText('Tab 1').closest('[data-tab-id]')!;
|
|
fireEvent.mouseDown(tab, { button: 1 });
|
|
expect(mockOnTabClose).toHaveBeenCalledWith('tab-1');
|
|
});
|
|
|
|
it('does not close on left-click mouseDown', () => {
|
|
const tabs = [createTab({ id: 'tab-1', name: 'Tab 1' })];
|
|
|
|
render(
|
|
<TabBar
|
|
tabs={tabs}
|
|
activeTabId="tab-1"
|
|
theme={mockTheme}
|
|
onTabSelect={mockOnTabSelect}
|
|
onTabClose={mockOnTabClose}
|
|
onNewTab={mockOnNewTab}
|
|
/>
|
|
);
|
|
|
|
const tab = screen.getByText('Tab 1').closest('[data-tab-id]')!;
|
|
fireEvent.mouseDown(tab, { button: 0 });
|
|
expect(mockOnTabClose).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('new tab', () => {
|
|
it('calls onNewTab when new tab button is clicked', () => {
|
|
render(
|
|
<TabBar
|
|
tabs={[createTab()]}
|
|
activeTabId="tab-1"
|
|
theme={mockTheme}
|
|
onTabSelect={mockOnTabSelect}
|
|
onTabClose={mockOnTabClose}
|
|
onNewTab={mockOnNewTab}
|
|
/>
|
|
);
|
|
|
|
fireEvent.click(screen.getByTitle('New tab (Cmd+T)'));
|
|
expect(mockOnNewTab).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('tab indicators', () => {
|
|
it('shows busy indicator when tab is busy', () => {
|
|
const tabs = [createTab({ id: 'tab-1', name: 'Tab 1', state: 'busy' })];
|
|
|
|
const { container } = render(
|
|
<TabBar
|
|
tabs={tabs}
|
|
activeTabId="tab-1"
|
|
theme={mockTheme}
|
|
onTabSelect={mockOnTabSelect}
|
|
onTabClose={mockOnTabClose}
|
|
onNewTab={mockOnNewTab}
|
|
/>
|
|
);
|
|
|
|
const busyDot = container.querySelector('.animate-pulse');
|
|
expect(busyDot).toBeInTheDocument();
|
|
expect(busyDot).toHaveStyle({ backgroundColor: mockTheme.colors.warning });
|
|
});
|
|
|
|
it('shows unread indicator for inactive tab with unread messages', () => {
|
|
const tabs = [
|
|
createTab({ id: 'tab-1', name: 'Tab 1' }),
|
|
createTab({ id: 'tab-2', name: 'Tab 2', hasUnread: true }),
|
|
];
|
|
|
|
const { container } = render(
|
|
<TabBar
|
|
tabs={tabs}
|
|
activeTabId="tab-1"
|
|
theme={mockTheme}
|
|
onTabSelect={mockOnTabSelect}
|
|
onTabClose={mockOnTabClose}
|
|
onNewTab={mockOnNewTab}
|
|
/>
|
|
);
|
|
|
|
const unreadDot = container.querySelector('[title="New messages"]');
|
|
expect(unreadDot).toBeInTheDocument();
|
|
expect(unreadDot).toHaveStyle({ backgroundColor: mockTheme.colors.accent });
|
|
});
|
|
|
|
it('shows unread indicator for active tab (when manually marked)', () => {
|
|
const tabs = [createTab({ id: 'tab-1', name: 'Tab 1', hasUnread: true })];
|
|
|
|
const { container } = render(
|
|
<TabBar
|
|
tabs={tabs}
|
|
activeTabId="tab-1"
|
|
theme={mockTheme}
|
|
onTabSelect={mockOnTabSelect}
|
|
onTabClose={mockOnTabClose}
|
|
onNewTab={mockOnNewTab}
|
|
/>
|
|
);
|
|
|
|
// Unread indicator should show immediately even on active tab
|
|
// This allows users to mark a tab as unread and see the indicator right away
|
|
const unreadDot = container.querySelector('[title="New messages"]');
|
|
expect(unreadDot).toBeInTheDocument();
|
|
expect(unreadDot).toHaveStyle({ backgroundColor: mockTheme.colors.accent });
|
|
});
|
|
|
|
it('does not show unread indicator for busy tab', () => {
|
|
const tabs = [
|
|
createTab({ id: 'tab-1', name: 'Tab 1' }),
|
|
createTab({ id: 'tab-2', name: 'Tab 2', hasUnread: true, state: 'busy' }),
|
|
];
|
|
|
|
const { container } = render(
|
|
<TabBar
|
|
tabs={tabs}
|
|
activeTabId="tab-1"
|
|
theme={mockTheme}
|
|
onTabSelect={mockOnTabSelect}
|
|
onTabClose={mockOnTabClose}
|
|
onNewTab={mockOnNewTab}
|
|
/>
|
|
);
|
|
|
|
expect(container.querySelector('[title="New messages"]')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('shows star indicator for starred tabs', () => {
|
|
const tabs = [createTab({ id: 'tab-1', name: 'Tab 1', starred: true })];
|
|
|
|
render(
|
|
<TabBar
|
|
tabs={tabs}
|
|
activeTabId="tab-1"
|
|
theme={mockTheme}
|
|
onTabSelect={mockOnTabSelect}
|
|
onTabClose={mockOnTabClose}
|
|
onNewTab={mockOnNewTab}
|
|
/>
|
|
);
|
|
|
|
expect(screen.getByTestId('star-icon')).toBeInTheDocument();
|
|
});
|
|
|
|
it('shows draft indicator for tabs with unsent input', () => {
|
|
const tabs = [createTab({ id: 'tab-1', name: 'Tab 1', inputValue: 'draft message' })];
|
|
|
|
render(
|
|
<TabBar
|
|
tabs={tabs}
|
|
activeTabId="tab-1"
|
|
theme={mockTheme}
|
|
onTabSelect={mockOnTabSelect}
|
|
onTabClose={mockOnTabClose}
|
|
onNewTab={mockOnNewTab}
|
|
/>
|
|
);
|
|
|
|
// The pencil icon component is rendered with testid
|
|
expect(screen.getByTestId('pencil-icon')).toBeInTheDocument();
|
|
});
|
|
|
|
it('shows draft indicator for tabs with staged images', () => {
|
|
const tabs = [createTab({ id: 'tab-1', name: 'Tab 1', stagedImages: ['image.png'] })];
|
|
|
|
render(
|
|
<TabBar
|
|
tabs={tabs}
|
|
activeTabId="tab-1"
|
|
theme={mockTheme}
|
|
onTabSelect={mockOnTabSelect}
|
|
onTabClose={mockOnTabClose}
|
|
onNewTab={mockOnNewTab}
|
|
/>
|
|
);
|
|
|
|
// The pencil icon component is rendered with testid
|
|
expect(screen.getByTestId('pencil-icon')).toBeInTheDocument();
|
|
});
|
|
|
|
it('shows shortcut hints for first 9 tabs', () => {
|
|
const tabs = Array.from({ length: 10 }, (_, i) =>
|
|
createTab({ id: `tab-${i}`, name: `Tab ${i + 1}` })
|
|
);
|
|
|
|
render(
|
|
<TabBar
|
|
tabs={tabs}
|
|
activeTabId="tab-0"
|
|
theme={mockTheme}
|
|
onTabSelect={mockOnTabSelect}
|
|
onTabClose={mockOnTabClose}
|
|
onNewTab={mockOnNewTab}
|
|
/>
|
|
);
|
|
|
|
// Should show 1-9 but not 10
|
|
for (let i = 1; i <= 9; i++) {
|
|
expect(screen.getByText(String(i))).toBeInTheDocument();
|
|
}
|
|
expect(screen.queryByText('10')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('hides shortcut hints when showUnreadOnly is true', () => {
|
|
const tabs = [createTab({ id: 'tab-1', name: 'Tab 1' })];
|
|
|
|
render(
|
|
<TabBar
|
|
tabs={tabs}
|
|
activeTabId="tab-1"
|
|
theme={mockTheme}
|
|
onTabSelect={mockOnTabSelect}
|
|
onTabClose={mockOnTabClose}
|
|
onNewTab={mockOnNewTab}
|
|
showUnreadOnly={true}
|
|
/>
|
|
);
|
|
|
|
expect(screen.queryByText('1')).not.toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe('unread filter', () => {
|
|
it('toggles unread filter when button clicked (uncontrolled)', () => {
|
|
const tabs = [
|
|
createTab({ id: 'tab-1', name: 'Tab 1' }),
|
|
createTab({ id: 'tab-2', name: 'Tab 2', hasUnread: true }),
|
|
];
|
|
|
|
render(
|
|
<TabBar
|
|
tabs={tabs}
|
|
activeTabId="tab-1"
|
|
theme={mockTheme}
|
|
onTabSelect={mockOnTabSelect}
|
|
onTabClose={mockOnTabClose}
|
|
onNewTab={mockOnNewTab}
|
|
/>
|
|
);
|
|
|
|
// Initially both tabs visible
|
|
expect(screen.getByText('Tab 1')).toBeInTheDocument();
|
|
expect(screen.getByText('Tab 2')).toBeInTheDocument();
|
|
|
|
// Toggle filter
|
|
fireEvent.click(screen.getByTitle(/Filter unread tabs/));
|
|
|
|
// Now only unread and active tab visible
|
|
expect(screen.getByText('Tab 1')).toBeInTheDocument(); // Active
|
|
expect(screen.getByText('Tab 2')).toBeInTheDocument(); // Unread
|
|
});
|
|
|
|
it('calls onToggleUnreadFilter when provided (controlled)', () => {
|
|
const tabs = [createTab({ id: 'tab-1', name: 'Tab 1' })];
|
|
|
|
render(
|
|
<TabBar
|
|
tabs={tabs}
|
|
activeTabId="tab-1"
|
|
theme={mockTheme}
|
|
onTabSelect={mockOnTabSelect}
|
|
onTabClose={mockOnTabClose}
|
|
onNewTab={mockOnNewTab}
|
|
onToggleUnreadFilter={mockOnToggleUnreadFilter}
|
|
/>
|
|
);
|
|
|
|
fireEvent.click(screen.getByTitle(/Filter unread tabs/));
|
|
expect(mockOnToggleUnreadFilter).toHaveBeenCalled();
|
|
});
|
|
|
|
it('shows empty state when filter is on but no unread tabs', () => {
|
|
const tabs = [createTab({ id: 'tab-1', name: 'Tab 1' })];
|
|
|
|
render(
|
|
<TabBar
|
|
tabs={tabs}
|
|
activeTabId="tab-2" // Different from tab-1
|
|
theme={mockTheme}
|
|
onTabSelect={mockOnTabSelect}
|
|
onTabClose={mockOnTabClose}
|
|
onNewTab={mockOnNewTab}
|
|
showUnreadOnly={true}
|
|
/>
|
|
);
|
|
|
|
expect(screen.getByText('No unread tabs')).toBeInTheDocument();
|
|
});
|
|
|
|
it('includes tabs with drafts in filtered view', () => {
|
|
const tabs = [
|
|
createTab({ id: 'tab-1', name: 'Tab 1' }),
|
|
createTab({ id: 'tab-2', name: 'Draft Tab', inputValue: 'draft' }),
|
|
];
|
|
|
|
render(
|
|
<TabBar
|
|
tabs={tabs}
|
|
activeTabId="tab-3" // Not in the list
|
|
theme={mockTheme}
|
|
onTabSelect={mockOnTabSelect}
|
|
onTabClose={mockOnTabClose}
|
|
onNewTab={mockOnNewTab}
|
|
showUnreadOnly={true}
|
|
/>
|
|
);
|
|
|
|
// Only draft tab should be visible
|
|
expect(screen.queryByText('Tab 1')).not.toBeInTheDocument();
|
|
expect(screen.getByText('Draft Tab')).toBeInTheDocument();
|
|
});
|
|
|
|
it('updates filter button title based on state', () => {
|
|
const tabs = [createTab({ id: 'tab-1', name: 'Tab 1' })];
|
|
|
|
const { rerender } = render(
|
|
<TabBar
|
|
tabs={tabs}
|
|
activeTabId="tab-1"
|
|
theme={mockTheme}
|
|
onTabSelect={mockOnTabSelect}
|
|
onTabClose={mockOnTabClose}
|
|
onNewTab={mockOnNewTab}
|
|
showUnreadOnly={false}
|
|
/>
|
|
);
|
|
|
|
expect(screen.getByTitle('Filter unread tabs (Cmd+U)')).toBeInTheDocument();
|
|
|
|
rerender(
|
|
<TabBar
|
|
tabs={tabs}
|
|
activeTabId="tab-1"
|
|
theme={mockTheme}
|
|
onTabSelect={mockOnTabSelect}
|
|
onTabClose={mockOnTabClose}
|
|
onNewTab={mockOnNewTab}
|
|
showUnreadOnly={true}
|
|
/>
|
|
);
|
|
|
|
expect(screen.getByTitle('Showing unread only (Cmd+U)')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe('tab search', () => {
|
|
it('calls onOpenTabSearch when search button clicked', () => {
|
|
render(
|
|
<TabBar
|
|
tabs={[createTab()]}
|
|
activeTabId="tab-1"
|
|
theme={mockTheme}
|
|
onTabSelect={mockOnTabSelect}
|
|
onTabClose={mockOnTabClose}
|
|
onNewTab={mockOnNewTab}
|
|
onOpenTabSearch={mockOnOpenTabSearch}
|
|
/>
|
|
);
|
|
|
|
fireEvent.click(screen.getByTitle('Search tabs (Cmd+Shift+O)'));
|
|
expect(mockOnOpenTabSearch).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('drag and drop', () => {
|
|
it('handles drag start', () => {
|
|
const tabs = [createTab({ id: 'tab-1', name: 'Tab 1' })];
|
|
|
|
render(
|
|
<TabBar
|
|
tabs={tabs}
|
|
activeTabId="tab-1"
|
|
theme={mockTheme}
|
|
onTabSelect={mockOnTabSelect}
|
|
onTabClose={mockOnTabClose}
|
|
onNewTab={mockOnNewTab}
|
|
onTabReorder={mockOnTabReorder}
|
|
/>
|
|
);
|
|
|
|
const tab = screen.getByText('Tab 1').closest('[data-tab-id]')!;
|
|
const dataTransfer = {
|
|
effectAllowed: '',
|
|
setData: vi.fn(),
|
|
getData: vi.fn().mockReturnValue('tab-1'),
|
|
};
|
|
|
|
fireEvent.dragStart(tab, { dataTransfer });
|
|
|
|
expect(dataTransfer.effectAllowed).toBe('move');
|
|
expect(dataTransfer.setData).toHaveBeenCalledWith('text/plain', 'tab-1');
|
|
});
|
|
|
|
it('handles drag over', () => {
|
|
const tabs = [
|
|
createTab({ id: 'tab-1', name: 'Tab 1' }),
|
|
createTab({ id: 'tab-2', name: 'Tab 2' }),
|
|
];
|
|
|
|
render(
|
|
<TabBar
|
|
tabs={tabs}
|
|
activeTabId="tab-1"
|
|
theme={mockTheme}
|
|
onTabSelect={mockOnTabSelect}
|
|
onTabClose={mockOnTabClose}
|
|
onNewTab={mockOnNewTab}
|
|
onTabReorder={mockOnTabReorder}
|
|
/>
|
|
);
|
|
|
|
const tab2 = screen.getByText('Tab 2').closest('[data-tab-id]')!;
|
|
const dataTransfer = {
|
|
dropEffect: '',
|
|
};
|
|
|
|
const event = fireEvent.dragOver(tab2, { dataTransfer });
|
|
expect(dataTransfer.dropEffect).toBe('move');
|
|
});
|
|
|
|
it('handles drop and reorders tabs', () => {
|
|
const tabs = [
|
|
createTab({ id: 'tab-1', name: 'Tab 1' }),
|
|
createTab({ id: 'tab-2', name: 'Tab 2' }),
|
|
];
|
|
|
|
render(
|
|
<TabBar
|
|
tabs={tabs}
|
|
activeTabId="tab-1"
|
|
theme={mockTheme}
|
|
onTabSelect={mockOnTabSelect}
|
|
onTabClose={mockOnTabClose}
|
|
onNewTab={mockOnNewTab}
|
|
onTabReorder={mockOnTabReorder}
|
|
/>
|
|
);
|
|
|
|
const tab1 = screen.getByText('Tab 1').closest('[data-tab-id]')!;
|
|
const tab2 = screen.getByText('Tab 2').closest('[data-tab-id]')!;
|
|
|
|
// Start dragging tab-1
|
|
fireEvent.dragStart(tab1, {
|
|
dataTransfer: {
|
|
effectAllowed: '',
|
|
setData: vi.fn(),
|
|
getData: vi.fn().mockReturnValue('tab-1'),
|
|
},
|
|
});
|
|
|
|
// Drop on tab-2
|
|
fireEvent.drop(tab2, {
|
|
dataTransfer: {
|
|
getData: vi.fn().mockReturnValue('tab-1'),
|
|
},
|
|
});
|
|
|
|
expect(mockOnTabReorder).toHaveBeenCalledWith(0, 1);
|
|
});
|
|
|
|
it('does not reorder when dropping on same tab', () => {
|
|
const tabs = [createTab({ id: 'tab-1', name: 'Tab 1' })];
|
|
|
|
render(
|
|
<TabBar
|
|
tabs={tabs}
|
|
activeTabId="tab-1"
|
|
theme={mockTheme}
|
|
onTabSelect={mockOnTabSelect}
|
|
onTabClose={mockOnTabClose}
|
|
onNewTab={mockOnNewTab}
|
|
onTabReorder={mockOnTabReorder}
|
|
/>
|
|
);
|
|
|
|
const tab = screen.getByText('Tab 1').closest('[data-tab-id]')!;
|
|
|
|
fireEvent.drop(tab, {
|
|
dataTransfer: {
|
|
getData: vi.fn().mockReturnValue('tab-1'),
|
|
},
|
|
});
|
|
|
|
expect(mockOnTabReorder).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('handles drag end', () => {
|
|
const tabs = [createTab({ id: 'tab-1', name: 'Tab 1' })];
|
|
|
|
render(
|
|
<TabBar
|
|
tabs={tabs}
|
|
activeTabId="tab-1"
|
|
theme={mockTheme}
|
|
onTabSelect={mockOnTabSelect}
|
|
onTabClose={mockOnTabClose}
|
|
onNewTab={mockOnNewTab}
|
|
onTabReorder={mockOnTabReorder}
|
|
/>
|
|
);
|
|
|
|
const tab = screen.getByText('Tab 1').closest('[data-tab-id]')!;
|
|
|
|
// Start drag to set draggingTabId
|
|
fireEvent.dragStart(tab, {
|
|
dataTransfer: {
|
|
effectAllowed: '',
|
|
setData: vi.fn(),
|
|
},
|
|
});
|
|
|
|
// Drag end should reset state
|
|
fireEvent.dragEnd(tab);
|
|
|
|
// Tab should no longer have opacity-50 class (dragging state)
|
|
expect(tab).not.toHaveClass('opacity-50');
|
|
});
|
|
});
|
|
|
|
describe('hover overlay', () => {
|
|
it('shows overlay after hover delay for tabs with agentSessionId', async () => {
|
|
const tabs = [
|
|
createTab({
|
|
id: 'tab-1',
|
|
name: 'Tab 1',
|
|
agentSessionId: 'abc123-def456',
|
|
}),
|
|
];
|
|
|
|
render(
|
|
<TabBar
|
|
tabs={tabs}
|
|
activeTabId="tab-1"
|
|
theme={mockTheme}
|
|
onTabSelect={mockOnTabSelect}
|
|
onTabClose={mockOnTabClose}
|
|
onNewTab={mockOnNewTab}
|
|
onTabStar={mockOnTabStar}
|
|
onRequestRename={mockOnRequestRename}
|
|
/>
|
|
);
|
|
|
|
const tab = screen.getByText('Tab 1').closest('[data-tab-id]')!;
|
|
fireEvent.mouseEnter(tab);
|
|
|
|
// Overlay not visible yet
|
|
expect(screen.queryByText('Copy Session ID')).not.toBeInTheDocument();
|
|
|
|
// Advance timers past the 400ms delay
|
|
act(() => {
|
|
vi.advanceTimersByTime(450);
|
|
});
|
|
|
|
// Now overlay should be visible
|
|
expect(screen.getByText('Copy Session ID')).toBeInTheDocument();
|
|
expect(screen.getByText('Star Session')).toBeInTheDocument();
|
|
expect(screen.getByText('Rename Tab')).toBeInTheDocument();
|
|
});
|
|
|
|
it('does not show overlay for tabs without agentSessionId', () => {
|
|
const tabs = [
|
|
createTab({
|
|
id: 'tab-1',
|
|
name: '',
|
|
agentSessionId: undefined,
|
|
}),
|
|
];
|
|
|
|
render(
|
|
<TabBar
|
|
tabs={tabs}
|
|
activeTabId="tab-1"
|
|
theme={mockTheme}
|
|
onTabSelect={mockOnTabSelect}
|
|
onTabClose={mockOnTabClose}
|
|
onNewTab={mockOnNewTab}
|
|
/>
|
|
);
|
|
|
|
const tab = screen.getByText('New Session').closest('[data-tab-id]')!;
|
|
fireEvent.mouseEnter(tab);
|
|
|
|
act(() => {
|
|
vi.advanceTimersByTime(500);
|
|
});
|
|
|
|
expect(screen.queryByText('Copy Session ID')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('closes overlay on mouse leave', async () => {
|
|
const tabs = [
|
|
createTab({
|
|
id: 'tab-1',
|
|
name: 'Tab 1',
|
|
agentSessionId: 'abc123',
|
|
}),
|
|
];
|
|
|
|
render(
|
|
<TabBar
|
|
tabs={tabs}
|
|
activeTabId="tab-1"
|
|
theme={mockTheme}
|
|
onTabSelect={mockOnTabSelect}
|
|
onTabClose={mockOnTabClose}
|
|
onNewTab={mockOnNewTab}
|
|
onTabStar={mockOnTabStar}
|
|
/>
|
|
);
|
|
|
|
const tab = screen.getByText('Tab 1').closest('[data-tab-id]')!;
|
|
|
|
// Open overlay
|
|
fireEvent.mouseEnter(tab);
|
|
act(() => {
|
|
vi.advanceTimersByTime(450);
|
|
});
|
|
expect(screen.getByText('Copy Session ID')).toBeInTheDocument();
|
|
|
|
// Leave tab
|
|
fireEvent.mouseLeave(tab);
|
|
|
|
// Wait for close delay
|
|
act(() => {
|
|
vi.advanceTimersByTime(150);
|
|
});
|
|
|
|
expect(screen.queryByText('Copy Session ID')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('keeps overlay open when mouse enters overlay', async () => {
|
|
const tabs = [
|
|
createTab({
|
|
id: 'tab-1',
|
|
name: 'Tab 1',
|
|
agentSessionId: 'abc123',
|
|
}),
|
|
];
|
|
|
|
render(
|
|
<TabBar
|
|
tabs={tabs}
|
|
activeTabId="tab-1"
|
|
theme={mockTheme}
|
|
onTabSelect={mockOnTabSelect}
|
|
onTabClose={mockOnTabClose}
|
|
onNewTab={mockOnNewTab}
|
|
onTabStar={mockOnTabStar}
|
|
/>
|
|
);
|
|
|
|
const tab = screen.getByText('Tab 1').closest('[data-tab-id]')!;
|
|
|
|
// Open overlay
|
|
fireEvent.mouseEnter(tab);
|
|
act(() => {
|
|
vi.advanceTimersByTime(450);
|
|
});
|
|
|
|
const overlay = screen.getByText('Copy Session ID').closest('.fixed')!;
|
|
|
|
// Leave tab but enter overlay
|
|
fireEvent.mouseLeave(tab);
|
|
fireEvent.mouseEnter(overlay);
|
|
|
|
// Wait past close delay
|
|
act(() => {
|
|
vi.advanceTimersByTime(200);
|
|
});
|
|
|
|
// Overlay should still be visible
|
|
expect(screen.getByText('Copy Session ID')).toBeInTheDocument();
|
|
});
|
|
|
|
it('closes overlay when mouse leaves overlay', async () => {
|
|
const tabs = [
|
|
createTab({
|
|
id: 'tab-1',
|
|
name: 'Tab 1',
|
|
agentSessionId: 'abc123',
|
|
}),
|
|
];
|
|
|
|
render(
|
|
<TabBar
|
|
tabs={tabs}
|
|
activeTabId="tab-1"
|
|
theme={mockTheme}
|
|
onTabSelect={mockOnTabSelect}
|
|
onTabClose={mockOnTabClose}
|
|
onNewTab={mockOnNewTab}
|
|
onTabStar={mockOnTabStar}
|
|
/>
|
|
);
|
|
|
|
const tab = screen.getByText('Tab 1').closest('[data-tab-id]')!;
|
|
|
|
// Open overlay
|
|
fireEvent.mouseEnter(tab);
|
|
act(() => {
|
|
vi.advanceTimersByTime(450);
|
|
});
|
|
|
|
const overlay = screen.getByText('Copy Session ID').closest('.fixed')!;
|
|
|
|
// Leave tab but enter overlay (to keep it open)
|
|
fireEvent.mouseLeave(tab);
|
|
fireEvent.mouseEnter(overlay);
|
|
|
|
// Verify overlay is still visible
|
|
expect(screen.getByText('Copy Session ID')).toBeInTheDocument();
|
|
|
|
// Now leave the overlay
|
|
fireEvent.mouseLeave(overlay);
|
|
|
|
// Overlay should close immediately
|
|
expect(screen.queryByText('Copy Session ID')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('prevents click event propagation on overlay', async () => {
|
|
const tabs = [
|
|
createTab({
|
|
id: 'tab-1',
|
|
name: 'Tab 1',
|
|
agentSessionId: 'abc123',
|
|
}),
|
|
];
|
|
|
|
render(
|
|
<TabBar
|
|
tabs={tabs}
|
|
activeTabId="tab-1"
|
|
theme={mockTheme}
|
|
onTabSelect={mockOnTabSelect}
|
|
onTabClose={mockOnTabClose}
|
|
onNewTab={mockOnNewTab}
|
|
onTabStar={mockOnTabStar}
|
|
/>
|
|
);
|
|
|
|
const tab = screen.getByText('Tab 1').closest('[data-tab-id]')!;
|
|
|
|
// Open overlay
|
|
fireEvent.mouseEnter(tab);
|
|
act(() => {
|
|
vi.advanceTimersByTime(450);
|
|
});
|
|
|
|
const overlay = screen.getByText('Copy Session ID').closest('.fixed')!;
|
|
|
|
// Click on overlay should not propagate
|
|
fireEvent.click(overlay);
|
|
|
|
// Overlay should still be open (event was stopped)
|
|
expect(screen.getByText('Copy Session ID')).toBeInTheDocument();
|
|
});
|
|
|
|
it('copies session ID to clipboard', async () => {
|
|
const tabs = [
|
|
createTab({
|
|
id: 'tab-1',
|
|
name: 'Tab 1',
|
|
agentSessionId: 'abc123-xyz789',
|
|
}),
|
|
];
|
|
|
|
render(
|
|
<TabBar
|
|
tabs={tabs}
|
|
activeTabId="tab-1"
|
|
theme={mockTheme}
|
|
onTabSelect={mockOnTabSelect}
|
|
onTabClose={mockOnTabClose}
|
|
onNewTab={mockOnNewTab}
|
|
/>
|
|
);
|
|
|
|
const tab = screen.getByText('Tab 1').closest('[data-tab-id]')!;
|
|
fireEvent.mouseEnter(tab);
|
|
act(() => {
|
|
vi.advanceTimersByTime(450);
|
|
});
|
|
|
|
fireEvent.click(screen.getByText('Copy Session ID'));
|
|
|
|
expect(navigator.clipboard.writeText).toHaveBeenCalledWith('abc123-xyz789');
|
|
expect(screen.getByText('Copied!')).toBeInTheDocument();
|
|
|
|
// Reset after delay
|
|
act(() => {
|
|
vi.advanceTimersByTime(1600);
|
|
});
|
|
expect(screen.queryByText('Copied!')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('calls onTabStar when star button clicked', async () => {
|
|
const tabs = [
|
|
createTab({
|
|
id: 'tab-1',
|
|
name: 'Tab 1',
|
|
agentSessionId: 'abc123',
|
|
starred: false,
|
|
}),
|
|
];
|
|
|
|
render(
|
|
<TabBar
|
|
tabs={tabs}
|
|
activeTabId="tab-1"
|
|
theme={mockTheme}
|
|
onTabSelect={mockOnTabSelect}
|
|
onTabClose={mockOnTabClose}
|
|
onNewTab={mockOnNewTab}
|
|
onTabStar={mockOnTabStar}
|
|
/>
|
|
);
|
|
|
|
const tab = screen.getByText('Tab 1').closest('[data-tab-id]')!;
|
|
fireEvent.mouseEnter(tab);
|
|
act(() => {
|
|
vi.advanceTimersByTime(450);
|
|
});
|
|
|
|
fireEvent.click(screen.getByText('Star Session'));
|
|
expect(mockOnTabStar).toHaveBeenCalledWith('tab-1', true);
|
|
});
|
|
|
|
it('shows "Unstar Session" for starred tabs', async () => {
|
|
const tabs = [
|
|
createTab({
|
|
id: 'tab-1',
|
|
name: 'Tab 1',
|
|
agentSessionId: 'abc123',
|
|
starred: true,
|
|
}),
|
|
];
|
|
|
|
render(
|
|
<TabBar
|
|
tabs={tabs}
|
|
activeTabId="tab-1"
|
|
theme={mockTheme}
|
|
onTabSelect={mockOnTabSelect}
|
|
onTabClose={mockOnTabClose}
|
|
onNewTab={mockOnNewTab}
|
|
onTabStar={mockOnTabStar}
|
|
/>
|
|
);
|
|
|
|
const tab = screen.getByText('Tab 1').closest('[data-tab-id]')!;
|
|
fireEvent.mouseEnter(tab);
|
|
act(() => {
|
|
vi.advanceTimersByTime(450);
|
|
});
|
|
|
|
expect(screen.getByText('Unstar Session')).toBeInTheDocument();
|
|
});
|
|
|
|
it('calls onRequestRename when rename clicked', async () => {
|
|
const tabs = [
|
|
createTab({
|
|
id: 'tab-1',
|
|
name: 'Tab 1',
|
|
agentSessionId: 'abc123',
|
|
}),
|
|
];
|
|
|
|
render(
|
|
<TabBar
|
|
tabs={tabs}
|
|
activeTabId="tab-1"
|
|
theme={mockTheme}
|
|
onTabSelect={mockOnTabSelect}
|
|
onTabClose={mockOnTabClose}
|
|
onNewTab={mockOnNewTab}
|
|
onRequestRename={mockOnRequestRename}
|
|
/>
|
|
);
|
|
|
|
const tab = screen.getByText('Tab 1').closest('[data-tab-id]')!;
|
|
fireEvent.mouseEnter(tab);
|
|
act(() => {
|
|
vi.advanceTimersByTime(450);
|
|
});
|
|
|
|
fireEvent.click(screen.getByText('Rename Tab'));
|
|
expect(mockOnRequestRename).toHaveBeenCalledWith('tab-1');
|
|
});
|
|
|
|
it('calls onTabMarkUnread when Mark as Unread clicked', async () => {
|
|
const tabs = [
|
|
createTab({
|
|
id: 'tab-1',
|
|
name: 'Tab 1',
|
|
agentSessionId: 'abc123',
|
|
}),
|
|
];
|
|
|
|
render(
|
|
<TabBar
|
|
tabs={tabs}
|
|
activeTabId="tab-1"
|
|
theme={mockTheme}
|
|
onTabSelect={mockOnTabSelect}
|
|
onTabClose={mockOnTabClose}
|
|
onNewTab={mockOnNewTab}
|
|
onTabMarkUnread={mockOnTabMarkUnread}
|
|
/>
|
|
);
|
|
|
|
const tab = screen.getByText('Tab 1').closest('[data-tab-id]')!;
|
|
fireEvent.mouseEnter(tab);
|
|
act(() => {
|
|
vi.advanceTimersByTime(450);
|
|
});
|
|
|
|
fireEvent.click(screen.getByText('Mark as Unread'));
|
|
expect(mockOnTabMarkUnread).toHaveBeenCalledWith('tab-1');
|
|
});
|
|
|
|
it('displays session name in overlay header', async () => {
|
|
const tabs = [
|
|
createTab({
|
|
id: 'tab-1',
|
|
name: 'My Session Name',
|
|
agentSessionId: 'abc123',
|
|
}),
|
|
];
|
|
|
|
render(
|
|
<TabBar
|
|
tabs={tabs}
|
|
activeTabId="tab-1"
|
|
theme={mockTheme}
|
|
onTabSelect={mockOnTabSelect}
|
|
onTabClose={mockOnTabClose}
|
|
onNewTab={mockOnNewTab}
|
|
/>
|
|
);
|
|
|
|
const tab = screen.getByText('My Session Name').closest('[data-tab-id]')!;
|
|
fireEvent.mouseEnter(tab);
|
|
act(() => {
|
|
vi.advanceTimersByTime(450);
|
|
});
|
|
|
|
// Session name appears in overlay header
|
|
const overlayNames = screen.getAllByText('My Session Name');
|
|
expect(overlayNames.length).toBeGreaterThan(1); // Tab name + overlay header
|
|
});
|
|
|
|
it('displays session ID in overlay header', async () => {
|
|
const tabs = [
|
|
createTab({
|
|
id: 'tab-1',
|
|
name: '',
|
|
agentSessionId: 'full-session-id-12345',
|
|
}),
|
|
];
|
|
|
|
render(
|
|
<TabBar
|
|
tabs={tabs}
|
|
activeTabId="tab-1"
|
|
theme={mockTheme}
|
|
onTabSelect={mockOnTabSelect}
|
|
onTabClose={mockOnTabClose}
|
|
onNewTab={mockOnNewTab}
|
|
/>
|
|
);
|
|
|
|
const tab = screen.getByText('FULL').closest('[data-tab-id]')!;
|
|
fireEvent.mouseEnter(tab);
|
|
act(() => {
|
|
vi.advanceTimersByTime(450);
|
|
});
|
|
|
|
expect(screen.getByText('full-session-id-12345')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe('separators', () => {
|
|
it('shows separators between inactive tabs', () => {
|
|
const tabs = [
|
|
createTab({ id: 'tab-1', name: 'Tab 1' }),
|
|
createTab({ id: 'tab-2', name: 'Tab 2' }),
|
|
createTab({ id: 'tab-3', name: 'Tab 3' }),
|
|
];
|
|
|
|
const { container } = render(
|
|
<TabBar
|
|
tabs={tabs}
|
|
activeTabId="tab-1"
|
|
theme={mockTheme}
|
|
onTabSelect={mockOnTabSelect}
|
|
onTabClose={mockOnTabClose}
|
|
onNewTab={mockOnNewTab}
|
|
/>
|
|
);
|
|
|
|
// Separators between inactive tabs (tab-2 and tab-3)
|
|
const separators = container.querySelectorAll('.w-px');
|
|
expect(separators.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('does not show separator next to active tab', () => {
|
|
const tabs = [
|
|
createTab({ id: 'tab-1', name: 'Tab 1' }),
|
|
createTab({ id: 'tab-2', name: 'Tab 2' }),
|
|
];
|
|
|
|
const { container } = render(
|
|
<TabBar
|
|
tabs={tabs}
|
|
activeTabId="tab-2"
|
|
theme={mockTheme}
|
|
onTabSelect={mockOnTabSelect}
|
|
onTabClose={mockOnTabClose}
|
|
onNewTab={mockOnNewTab}
|
|
/>
|
|
);
|
|
|
|
// No separator when active tab is involved
|
|
const separators = container.querySelectorAll('.w-px');
|
|
// Separator should not appear before tab-2 (which is active)
|
|
expect(separators.length).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('scroll behavior', () => {
|
|
it('scrolls active tab into view when activeTabId changes', async () => {
|
|
// Mock requestAnimationFrame
|
|
const rafSpy = vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => {
|
|
cb(0);
|
|
return 0;
|
|
});
|
|
const scrollIntoViewSpy = vi.fn();
|
|
|
|
const tabs = [
|
|
createTab({ id: 'tab-1', name: 'Tab 1' }),
|
|
createTab({ id: 'tab-2', name: 'Tab 2' }),
|
|
];
|
|
|
|
const { rerender, container } = render(
|
|
<TabBar
|
|
tabs={tabs}
|
|
activeTabId="tab-1"
|
|
theme={mockTheme}
|
|
onTabSelect={mockOnTabSelect}
|
|
onTabClose={mockOnTabClose}
|
|
onNewTab={mockOnNewTab}
|
|
/>
|
|
);
|
|
|
|
// Mock scrollIntoView on the tab elements
|
|
const tabElements = container.querySelectorAll('[data-tab-id]');
|
|
tabElements.forEach((el) => {
|
|
(el as HTMLElement).scrollIntoView = scrollIntoViewSpy;
|
|
});
|
|
|
|
// Change active tab
|
|
rerender(
|
|
<TabBar
|
|
tabs={tabs}
|
|
activeTabId="tab-2"
|
|
theme={mockTheme}
|
|
onTabSelect={mockOnTabSelect}
|
|
onTabClose={mockOnTabClose}
|
|
onNewTab={mockOnNewTab}
|
|
/>
|
|
);
|
|
|
|
// Re-mock scrollIntoView on tab elements after rerender
|
|
const newTabElements = container.querySelectorAll('[data-tab-id]');
|
|
newTabElements.forEach((el) => {
|
|
(el as HTMLElement).scrollIntoView = scrollIntoViewSpy;
|
|
});
|
|
|
|
// scrollIntoView should have been called via requestAnimationFrame
|
|
expect(scrollIntoViewSpy).toHaveBeenCalledWith({
|
|
inline: 'nearest',
|
|
behavior: 'smooth',
|
|
block: 'nearest',
|
|
});
|
|
|
|
rafSpy.mockRestore();
|
|
});
|
|
|
|
it('scrolls active tab into view when showUnreadOnly filter is toggled off', async () => {
|
|
// Mock requestAnimationFrame
|
|
const rafSpy = vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => {
|
|
cb(0);
|
|
return 0;
|
|
});
|
|
const scrollIntoViewSpy = vi.fn();
|
|
|
|
const tabs = [
|
|
createTab({ id: 'tab-1', name: 'Tab 1' }),
|
|
createTab({ id: 'tab-2', name: 'Tab 2', hasUnread: true }),
|
|
createTab({ id: 'tab-3', name: 'Tab 3' }),
|
|
];
|
|
|
|
const { rerender, container } = render(
|
|
<TabBar
|
|
tabs={tabs}
|
|
activeTabId="tab-3"
|
|
theme={mockTheme}
|
|
onTabSelect={mockOnTabSelect}
|
|
onTabClose={mockOnTabClose}
|
|
onNewTab={mockOnNewTab}
|
|
showUnreadOnly={true}
|
|
/>
|
|
);
|
|
|
|
// Mock scrollIntoView on the tab elements
|
|
const tabElements = container.querySelectorAll('[data-tab-id]');
|
|
tabElements.forEach((el) => {
|
|
(el as HTMLElement).scrollIntoView = scrollIntoViewSpy;
|
|
});
|
|
|
|
// Clear initial calls
|
|
scrollIntoViewSpy.mockClear();
|
|
|
|
// Toggle filter off - this should trigger scroll to active tab
|
|
rerender(
|
|
<TabBar
|
|
tabs={tabs}
|
|
activeTabId="tab-3"
|
|
theme={mockTheme}
|
|
onTabSelect={mockOnTabSelect}
|
|
onTabClose={mockOnTabClose}
|
|
onNewTab={mockOnNewTab}
|
|
showUnreadOnly={false}
|
|
/>
|
|
);
|
|
|
|
// Re-mock scrollIntoView on tab elements after rerender
|
|
const newTabElements = container.querySelectorAll('[data-tab-id]');
|
|
newTabElements.forEach((el) => {
|
|
(el as HTMLElement).scrollIntoView = scrollIntoViewSpy;
|
|
});
|
|
|
|
// scrollIntoView should have been called when filter was toggled
|
|
expect(scrollIntoViewSpy).toHaveBeenCalledWith({
|
|
inline: 'nearest',
|
|
behavior: 'smooth',
|
|
block: 'nearest',
|
|
});
|
|
|
|
rafSpy.mockRestore();
|
|
});
|
|
|
|
it('scrolls file tab into view when activeFileTabId changes', async () => {
|
|
// Mock requestAnimationFrame
|
|
const rafSpy = vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => {
|
|
cb(0);
|
|
return 0;
|
|
});
|
|
const scrollIntoViewSpy = vi.fn();
|
|
|
|
const tabs = [createTab({ id: 'tab-1', name: 'Tab 1' })];
|
|
const fileTab: FilePreviewTab = {
|
|
id: 'file-1',
|
|
path: '/path/to/file.ts',
|
|
name: 'file',
|
|
extension: '.ts',
|
|
};
|
|
const unifiedTabs = [
|
|
{ id: 'tab-1', type: 'ai' as const, data: tabs[0] },
|
|
{ id: 'file-1', type: 'file' as const, data: fileTab },
|
|
];
|
|
|
|
const { rerender, container } = render(
|
|
<TabBar
|
|
tabs={tabs}
|
|
activeTabId="tab-1"
|
|
theme={mockTheme}
|
|
onTabSelect={mockOnTabSelect}
|
|
onTabClose={mockOnTabClose}
|
|
onNewTab={mockOnNewTab}
|
|
unifiedTabs={unifiedTabs}
|
|
activeFileTabId={null}
|
|
onFileTabSelect={vi.fn()}
|
|
onFileTabClose={vi.fn()}
|
|
/>
|
|
);
|
|
|
|
// Mock scrollIntoView on the tab elements
|
|
const tabElements = container.querySelectorAll('[data-tab-id]');
|
|
tabElements.forEach((el) => {
|
|
(el as HTMLElement).scrollIntoView = scrollIntoViewSpy;
|
|
});
|
|
|
|
// Clear initial calls
|
|
scrollIntoViewSpy.mockClear();
|
|
|
|
// Select the file tab - this should trigger scroll to file tab
|
|
rerender(
|
|
<TabBar
|
|
tabs={tabs}
|
|
activeTabId="tab-1"
|
|
theme={mockTheme}
|
|
onTabSelect={mockOnTabSelect}
|
|
onTabClose={mockOnTabClose}
|
|
onNewTab={mockOnNewTab}
|
|
unifiedTabs={unifiedTabs}
|
|
activeFileTabId="file-1"
|
|
onFileTabSelect={vi.fn()}
|
|
onFileTabClose={vi.fn()}
|
|
/>
|
|
);
|
|
|
|
// Re-mock scrollIntoView on tab elements after rerender
|
|
const newTabElements = container.querySelectorAll('[data-tab-id]');
|
|
newTabElements.forEach((el) => {
|
|
(el as HTMLElement).scrollIntoView = scrollIntoViewSpy;
|
|
});
|
|
|
|
// scrollIntoView should have been called when file tab was selected
|
|
expect(scrollIntoViewSpy).toHaveBeenCalledWith({
|
|
inline: 'nearest',
|
|
behavior: 'smooth',
|
|
block: 'nearest',
|
|
});
|
|
|
|
rafSpy.mockRestore();
|
|
});
|
|
});
|
|
|
|
describe('styling', () => {
|
|
it('applies theme colors correctly', () => {
|
|
const tabs = [createTab({ id: 'tab-1', name: 'Tab 1' })];
|
|
|
|
const { container } = render(
|
|
<TabBar
|
|
tabs={tabs}
|
|
activeTabId="tab-1"
|
|
theme={mockTheme}
|
|
onTabSelect={mockOnTabSelect}
|
|
onTabClose={mockOnTabClose}
|
|
onNewTab={mockOnNewTab}
|
|
/>
|
|
);
|
|
|
|
const tabBar = container.firstChild as HTMLElement;
|
|
expect(tabBar).toHaveStyle({ backgroundColor: mockTheme.colors.bgSidebar });
|
|
expect(tabBar).toHaveStyle({ borderColor: mockTheme.colors.border });
|
|
});
|
|
|
|
it('applies hover effect on inactive tabs', () => {
|
|
const tabs = [
|
|
createTab({ id: 'tab-1', name: 'Tab 1' }),
|
|
createTab({ id: 'tab-2', name: 'Tab 2' }),
|
|
];
|
|
|
|
render(
|
|
<TabBar
|
|
tabs={tabs}
|
|
activeTabId="tab-1"
|
|
theme={mockTheme}
|
|
onTabSelect={mockOnTabSelect}
|
|
onTabClose={mockOnTabClose}
|
|
onNewTab={mockOnNewTab}
|
|
/>
|
|
);
|
|
|
|
const inactiveTab = screen.getByText('Tab 2').closest('[data-tab-id]')! as HTMLElement;
|
|
|
|
// Before hover - check inline style is not hover state
|
|
const initialBgColor = inactiveTab.style.backgroundColor;
|
|
expect(initialBgColor).not.toBe('rgba(255, 255, 255, 0.08)');
|
|
|
|
// Hover
|
|
fireEvent.mouseEnter(inactiveTab);
|
|
expect(inactiveTab.style.backgroundColor).toBe('rgba(255, 255, 255, 0.08)');
|
|
|
|
// Leave
|
|
fireEvent.mouseLeave(inactiveTab);
|
|
|
|
// After the timeout the state is set
|
|
act(() => {
|
|
vi.advanceTimersByTime(150);
|
|
});
|
|
|
|
// Background color should no longer be hover state
|
|
expect(inactiveTab.style.backgroundColor).not.toBe('rgba(255, 255, 255, 0.08)');
|
|
});
|
|
|
|
it('does not set title attribute on tabs (removed for cleaner UX)', () => {
|
|
// Tab title tooltips were intentionally removed to streamline the tab interaction feel
|
|
const tabs = [
|
|
createTab({
|
|
id: 'tab-1',
|
|
name: 'My Tab',
|
|
agentSessionId: 'session-123',
|
|
}),
|
|
];
|
|
|
|
render(
|
|
<TabBar
|
|
tabs={tabs}
|
|
activeTabId="tab-1"
|
|
theme={mockTheme}
|
|
onTabSelect={mockOnTabSelect}
|
|
onTabClose={mockOnTabClose}
|
|
onNewTab={mockOnNewTab}
|
|
/>
|
|
);
|
|
|
|
const tab = screen.getByText('My Tab').closest('[data-tab-id]')!;
|
|
expect(tab).not.toHaveAttribute('title');
|
|
});
|
|
});
|
|
|
|
describe('edge cases', () => {
|
|
it('handles empty tabs array', () => {
|
|
render(
|
|
<TabBar
|
|
tabs={[]}
|
|
activeTabId="nonexistent"
|
|
theme={mockTheme}
|
|
onTabSelect={mockOnTabSelect}
|
|
onTabClose={mockOnTabClose}
|
|
onNewTab={mockOnNewTab}
|
|
/>
|
|
);
|
|
|
|
// Should still render the new tab button
|
|
expect(screen.getByTitle('New tab (Cmd+T)')).toBeInTheDocument();
|
|
});
|
|
|
|
it('handles special characters in tab names', () => {
|
|
const tabs = [
|
|
createTab({
|
|
id: 'tab-1',
|
|
name: '<script>alert("xss")</script>',
|
|
}),
|
|
];
|
|
|
|
render(
|
|
<TabBar
|
|
tabs={tabs}
|
|
activeTabId="tab-1"
|
|
theme={mockTheme}
|
|
onTabSelect={mockOnTabSelect}
|
|
onTabClose={mockOnTabClose}
|
|
onNewTab={mockOnNewTab}
|
|
/>
|
|
);
|
|
|
|
// Text should be escaped, not executed
|
|
expect(screen.getByText('<script>alert("xss")</script>')).toBeInTheDocument();
|
|
});
|
|
|
|
it('handles unicode in tab names', () => {
|
|
const tabs = [
|
|
createTab({
|
|
id: 'tab-1',
|
|
name: '🎵 Music Tab 日本語',
|
|
}),
|
|
];
|
|
|
|
render(
|
|
<TabBar
|
|
tabs={tabs}
|
|
activeTabId="tab-1"
|
|
theme={mockTheme}
|
|
onTabSelect={mockOnTabSelect}
|
|
onTabClose={mockOnTabClose}
|
|
onNewTab={mockOnNewTab}
|
|
/>
|
|
);
|
|
|
|
expect(screen.getByText('🎵 Music Tab 日本語')).toBeInTheDocument();
|
|
});
|
|
|
|
it('handles very long tab names with truncation for inactive tabs', () => {
|
|
const longName = 'This is a very long tab name that should be truncated';
|
|
const tabs = [
|
|
createTab({ id: 'tab-1', name: 'Active Tab' }),
|
|
createTab({ id: 'tab-2', name: longName }),
|
|
];
|
|
|
|
render(
|
|
<TabBar
|
|
tabs={tabs}
|
|
activeTabId="tab-1"
|
|
theme={mockTheme}
|
|
onTabSelect={mockOnTabSelect}
|
|
onTabClose={mockOnTabClose}
|
|
onNewTab={mockOnNewTab}
|
|
/>
|
|
);
|
|
|
|
// Inactive tab should be truncated
|
|
const inactiveTabName = screen.getByText(longName);
|
|
expect(inactiveTabName).toHaveClass('truncate');
|
|
expect(inactiveTabName).toHaveClass('max-w-[120px]');
|
|
|
|
// Active tab should show full name without truncation
|
|
const activeTabName = screen.getByText('Active Tab');
|
|
expect(activeTabName).toHaveClass('whitespace-nowrap');
|
|
expect(activeTabName).not.toHaveClass('truncate');
|
|
});
|
|
|
|
it('handles many tabs', () => {
|
|
const tabs = Array.from({ length: 50 }, (_, i) =>
|
|
createTab({ id: `tab-${i}`, name: `Tab ${i + 1}` })
|
|
);
|
|
|
|
render(
|
|
<TabBar
|
|
tabs={tabs}
|
|
activeTabId="tab-0"
|
|
theme={mockTheme}
|
|
onTabSelect={mockOnTabSelect}
|
|
onTabClose={mockOnTabClose}
|
|
onNewTab={mockOnNewTab}
|
|
/>
|
|
);
|
|
|
|
expect(screen.getByText('Tab 1')).toBeInTheDocument();
|
|
expect(screen.getByText('Tab 50')).toBeInTheDocument();
|
|
});
|
|
|
|
it('handles whitespace-only inputValue (no draft indicator)', () => {
|
|
const tabs = [
|
|
createTab({
|
|
id: 'tab-1',
|
|
name: 'Tab 1',
|
|
inputValue: ' ', // whitespace only
|
|
}),
|
|
];
|
|
|
|
render(
|
|
<TabBar
|
|
tabs={tabs}
|
|
activeTabId="tab-1"
|
|
theme={mockTheme}
|
|
onTabSelect={mockOnTabSelect}
|
|
onTabClose={mockOnTabClose}
|
|
onNewTab={mockOnNewTab}
|
|
/>
|
|
);
|
|
|
|
expect(screen.queryByTitle('Has draft message')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('handles empty stagedImages array (no draft indicator)', () => {
|
|
const tabs = [
|
|
createTab({
|
|
id: 'tab-1',
|
|
name: 'Tab 1',
|
|
stagedImages: [],
|
|
}),
|
|
];
|
|
|
|
render(
|
|
<TabBar
|
|
tabs={tabs}
|
|
activeTabId="tab-1"
|
|
theme={mockTheme}
|
|
onTabSelect={mockOnTabSelect}
|
|
onTabClose={mockOnTabClose}
|
|
onNewTab={mockOnNewTab}
|
|
/>
|
|
);
|
|
|
|
expect(screen.queryByTitle('Has draft message')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('handles rapid tab selection', () => {
|
|
const tabs = [
|
|
createTab({ id: 'tab-1', name: 'Tab 1' }),
|
|
createTab({ id: 'tab-2', name: 'Tab 2' }),
|
|
createTab({ id: 'tab-3', name: 'Tab 3' }),
|
|
];
|
|
|
|
render(
|
|
<TabBar
|
|
tabs={tabs}
|
|
activeTabId="tab-1"
|
|
theme={mockTheme}
|
|
onTabSelect={mockOnTabSelect}
|
|
onTabClose={mockOnTabClose}
|
|
onNewTab={mockOnNewTab}
|
|
/>
|
|
);
|
|
|
|
fireEvent.click(screen.getByText('Tab 2'));
|
|
fireEvent.click(screen.getByText('Tab 3'));
|
|
fireEvent.click(screen.getByText('Tab 1'));
|
|
|
|
expect(mockOnTabSelect).toHaveBeenCalledTimes(3);
|
|
expect(mockOnTabSelect).toHaveBeenNthCalledWith(1, 'tab-2');
|
|
expect(mockOnTabSelect).toHaveBeenNthCalledWith(2, 'tab-3');
|
|
expect(mockOnTabSelect).toHaveBeenNthCalledWith(3, 'tab-1');
|
|
});
|
|
});
|
|
|
|
describe('overflow detection', () => {
|
|
it('makes new tab button sticky when tabs overflow', () => {
|
|
// Mock scrollWidth > clientWidth
|
|
const originalRef = React.useRef;
|
|
vi.spyOn(React, 'useRef').mockImplementation((initial) => {
|
|
const ref = originalRef(initial);
|
|
if (ref.current === null) {
|
|
Object.defineProperty(ref, 'current', {
|
|
get: () => ({
|
|
scrollWidth: 1000,
|
|
clientWidth: 500,
|
|
querySelector: vi.fn().mockReturnValue({
|
|
offsetLeft: 100,
|
|
offsetWidth: 80,
|
|
scrollIntoView: vi.fn(),
|
|
}),
|
|
scrollTo: vi.fn(),
|
|
}),
|
|
set: () => {},
|
|
});
|
|
}
|
|
return ref;
|
|
});
|
|
|
|
const tabs = Array.from({ length: 20 }, (_, i) =>
|
|
createTab({ id: `tab-${i}`, name: `Tab ${i + 1}` })
|
|
);
|
|
|
|
render(
|
|
<TabBar
|
|
tabs={tabs}
|
|
activeTabId="tab-0"
|
|
theme={mockTheme}
|
|
onTabSelect={mockOnTabSelect}
|
|
onTabClose={mockOnTabClose}
|
|
onNewTab={mockOnNewTab}
|
|
/>
|
|
);
|
|
|
|
// Wait for overflow check
|
|
act(() => {
|
|
vi.advanceTimersByTime(100);
|
|
});
|
|
|
|
vi.restoreAllMocks();
|
|
});
|
|
});
|
|
|
|
describe('tab hover overlay menu (tab move operations)', () => {
|
|
it('shows "Move to First Position" for non-first tabs', () => {
|
|
const tabs = [
|
|
createTab({ id: 'tab-1', name: 'Tab 1', agentSessionId: 'session-1' }),
|
|
createTab({ id: 'tab-2', name: 'Tab 2', agentSessionId: 'session-2' }),
|
|
createTab({ id: 'tab-3', name: 'Tab 3', agentSessionId: 'session-3' }),
|
|
];
|
|
|
|
render(
|
|
<TabBar
|
|
tabs={tabs}
|
|
activeTabId="tab-2"
|
|
theme={mockTheme}
|
|
onTabSelect={mockOnTabSelect}
|
|
onTabClose={mockOnTabClose}
|
|
onNewTab={mockOnNewTab}
|
|
onTabReorder={mockOnTabReorder}
|
|
/>
|
|
);
|
|
|
|
const tab = screen.getByText('Tab 2').closest('[data-tab-id]')!;
|
|
fireEvent.mouseEnter(tab);
|
|
|
|
act(() => {
|
|
vi.advanceTimersByTime(450);
|
|
});
|
|
|
|
expect(screen.getByText('Move to First Position')).toBeInTheDocument();
|
|
expect(screen.getByText('Move to Last Position')).toBeInTheDocument();
|
|
});
|
|
|
|
it('hides "Move to First Position" when hovering first tab', () => {
|
|
const tabs = [
|
|
createTab({ id: 'tab-1', name: 'Tab 1', agentSessionId: 'session-1' }),
|
|
createTab({ id: 'tab-2', name: 'Tab 2', agentSessionId: 'session-2' }),
|
|
];
|
|
|
|
render(
|
|
<TabBar
|
|
tabs={tabs}
|
|
activeTabId="tab-1"
|
|
theme={mockTheme}
|
|
onTabSelect={mockOnTabSelect}
|
|
onTabClose={mockOnTabClose}
|
|
onNewTab={mockOnNewTab}
|
|
onTabReorder={mockOnTabReorder}
|
|
/>
|
|
);
|
|
|
|
const tab = screen.getByText('Tab 1').closest('[data-tab-id]')!;
|
|
fireEvent.mouseEnter(tab);
|
|
|
|
act(() => {
|
|
vi.advanceTimersByTime(450);
|
|
});
|
|
|
|
// Move to First Position is hidden on first tab
|
|
expect(screen.queryByText('Move to First Position')).not.toBeInTheDocument();
|
|
// Move to Last Position is shown
|
|
expect(screen.getByText('Move to Last Position')).toBeInTheDocument();
|
|
});
|
|
|
|
it('hides "Move to Last Position" when hovering last tab', () => {
|
|
const tabs = [
|
|
createTab({ id: 'tab-1', name: 'Tab 1', agentSessionId: 'session-1' }),
|
|
createTab({ id: 'tab-2', name: 'Tab 2', agentSessionId: 'session-2' }),
|
|
];
|
|
|
|
render(
|
|
<TabBar
|
|
tabs={tabs}
|
|
activeTabId="tab-1"
|
|
theme={mockTheme}
|
|
onTabSelect={mockOnTabSelect}
|
|
onTabClose={mockOnTabClose}
|
|
onNewTab={mockOnNewTab}
|
|
onTabReorder={mockOnTabReorder}
|
|
/>
|
|
);
|
|
|
|
const tab = screen.getByText('Tab 2').closest('[data-tab-id]')!;
|
|
fireEvent.mouseEnter(tab);
|
|
|
|
act(() => {
|
|
vi.advanceTimersByTime(450);
|
|
});
|
|
|
|
// Move to Last Position is hidden on last tab
|
|
expect(screen.queryByText('Move to Last Position')).not.toBeInTheDocument();
|
|
// Move to First Position is shown
|
|
expect(screen.getByText('Move to First Position')).toBeInTheDocument();
|
|
});
|
|
|
|
it('hides both move options when only one tab exists', () => {
|
|
const tabs = [createTab({ id: 'tab-1', name: 'Tab 1', agentSessionId: 'session-1' })];
|
|
|
|
render(
|
|
<TabBar
|
|
tabs={tabs}
|
|
activeTabId="tab-1"
|
|
theme={mockTheme}
|
|
onTabSelect={mockOnTabSelect}
|
|
onTabClose={mockOnTabClose}
|
|
onNewTab={mockOnNewTab}
|
|
onTabReorder={mockOnTabReorder}
|
|
/>
|
|
);
|
|
|
|
const tab = screen.getByText('Tab 1').closest('[data-tab-id]')!;
|
|
fireEvent.mouseEnter(tab);
|
|
|
|
act(() => {
|
|
vi.advanceTimersByTime(450);
|
|
});
|
|
|
|
// Both move options are hidden when only one tab exists
|
|
expect(screen.queryByText('Move to First Position')).not.toBeInTheDocument();
|
|
expect(screen.queryByText('Move to Last Position')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('calls onTabReorder when "Move to First Position" is clicked', () => {
|
|
const tabs = [
|
|
createTab({ id: 'tab-1', name: 'Tab 1', agentSessionId: 'session-1' }),
|
|
createTab({ id: 'tab-2', name: 'Tab 2', agentSessionId: 'session-2' }),
|
|
createTab({ id: 'tab-3', name: 'Tab 3', agentSessionId: 'session-3' }),
|
|
];
|
|
|
|
render(
|
|
<TabBar
|
|
tabs={tabs}
|
|
activeTabId="tab-2"
|
|
theme={mockTheme}
|
|
onTabSelect={mockOnTabSelect}
|
|
onTabClose={mockOnTabClose}
|
|
onNewTab={mockOnNewTab}
|
|
onTabReorder={mockOnTabReorder}
|
|
/>
|
|
);
|
|
|
|
const tab = screen.getByText('Tab 3').closest('[data-tab-id]')!;
|
|
fireEvent.mouseEnter(tab);
|
|
|
|
act(() => {
|
|
vi.advanceTimersByTime(450);
|
|
});
|
|
|
|
fireEvent.click(screen.getByText('Move to First Position'));
|
|
|
|
// Should reorder from index 2 to index 0
|
|
expect(mockOnTabReorder).toHaveBeenCalledWith(2, 0);
|
|
});
|
|
|
|
it('calls onTabReorder when "Move to Last Position" is clicked', () => {
|
|
const tabs = [
|
|
createTab({ id: 'tab-1', name: 'Tab 1', agentSessionId: 'session-1' }),
|
|
createTab({ id: 'tab-2', name: 'Tab 2', agentSessionId: 'session-2' }),
|
|
createTab({ id: 'tab-3', name: 'Tab 3', agentSessionId: 'session-3' }),
|
|
];
|
|
|
|
render(
|
|
<TabBar
|
|
tabs={tabs}
|
|
activeTabId="tab-2"
|
|
theme={mockTheme}
|
|
onTabSelect={mockOnTabSelect}
|
|
onTabClose={mockOnTabClose}
|
|
onNewTab={mockOnNewTab}
|
|
onTabReorder={mockOnTabReorder}
|
|
/>
|
|
);
|
|
|
|
const tab = screen.getByText('Tab 1').closest('[data-tab-id]')!;
|
|
fireEvent.mouseEnter(tab);
|
|
|
|
act(() => {
|
|
vi.advanceTimersByTime(450);
|
|
});
|
|
|
|
fireEvent.click(screen.getByText('Move to Last Position'));
|
|
|
|
// Should reorder from index 0 to index 2
|
|
expect(mockOnTabReorder).toHaveBeenCalledWith(0, 2);
|
|
});
|
|
|
|
it('does not show move options when onTabReorder is not provided', () => {
|
|
const tabs = [
|
|
createTab({ id: 'tab-1', name: 'Tab 1', agentSessionId: 'session-1' }),
|
|
createTab({ id: 'tab-2', name: 'Tab 2', agentSessionId: 'session-2' }),
|
|
];
|
|
|
|
render(
|
|
<TabBar
|
|
tabs={tabs}
|
|
activeTabId="tab-1"
|
|
theme={mockTheme}
|
|
onTabSelect={mockOnTabSelect}
|
|
onTabClose={mockOnTabClose}
|
|
onNewTab={mockOnNewTab}
|
|
// onTabReorder not provided
|
|
/>
|
|
);
|
|
|
|
const tab = screen.getByText('Tab 2').closest('[data-tab-id]')!;
|
|
fireEvent.mouseEnter(tab);
|
|
|
|
act(() => {
|
|
vi.advanceTimersByTime(450);
|
|
});
|
|
|
|
// Move options should not be shown without onTabReorder
|
|
expect(screen.queryByText('Move to First Position')).not.toBeInTheDocument();
|
|
expect(screen.queryByText('Move to Last Position')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('closes overlay menu after move action', () => {
|
|
const tabs = [
|
|
createTab({ id: 'tab-1', name: 'Tab 1', agentSessionId: 'session-1' }),
|
|
createTab({ id: 'tab-2', name: 'Tab 2', agentSessionId: 'session-2' }),
|
|
];
|
|
|
|
render(
|
|
<TabBar
|
|
tabs={tabs}
|
|
activeTabId="tab-1"
|
|
theme={mockTheme}
|
|
onTabSelect={mockOnTabSelect}
|
|
onTabClose={mockOnTabClose}
|
|
onNewTab={mockOnNewTab}
|
|
onTabReorder={mockOnTabReorder}
|
|
/>
|
|
);
|
|
|
|
const tab = screen.getByText('Tab 2').closest('[data-tab-id]')!;
|
|
fireEvent.mouseEnter(tab);
|
|
|
|
act(() => {
|
|
vi.advanceTimersByTime(450);
|
|
});
|
|
|
|
expect(screen.getByText('Move to First Position')).toBeInTheDocument();
|
|
|
|
fireEvent.click(screen.getByText('Move to First Position'));
|
|
|
|
// Overlay should be closed after clicking Move
|
|
expect(screen.queryByText('Move to First Position')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('renders ChevronsLeft icon for Move to First Position', () => {
|
|
const tabs = [
|
|
createTab({ id: 'tab-1', name: 'Tab 1', agentSessionId: 'session-1' }),
|
|
createTab({ id: 'tab-2', name: 'Tab 2', agentSessionId: 'session-2' }),
|
|
];
|
|
|
|
render(
|
|
<TabBar
|
|
tabs={tabs}
|
|
activeTabId="tab-1"
|
|
theme={mockTheme}
|
|
onTabSelect={mockOnTabSelect}
|
|
onTabClose={mockOnTabClose}
|
|
onNewTab={mockOnNewTab}
|
|
onTabReorder={mockOnTabReorder}
|
|
/>
|
|
);
|
|
|
|
const tab = screen.getByText('Tab 2').closest('[data-tab-id]')!;
|
|
fireEvent.mouseEnter(tab);
|
|
|
|
act(() => {
|
|
vi.advanceTimersByTime(450);
|
|
});
|
|
|
|
expect(screen.getByTestId('chevrons-left-icon')).toBeInTheDocument();
|
|
});
|
|
|
|
it('renders ChevronsRight icon for Move to Last Position', () => {
|
|
const tabs = [
|
|
createTab({ id: 'tab-1', name: 'Tab 1', agentSessionId: 'session-1' }),
|
|
createTab({ id: 'tab-2', name: 'Tab 2', agentSessionId: 'session-2' }),
|
|
];
|
|
|
|
render(
|
|
<TabBar
|
|
tabs={tabs}
|
|
activeTabId="tab-1"
|
|
theme={mockTheme}
|
|
onTabSelect={mockOnTabSelect}
|
|
onTabClose={mockOnTabClose}
|
|
onNewTab={mockOnNewTab}
|
|
onTabReorder={mockOnTabReorder}
|
|
/>
|
|
);
|
|
|
|
const tab = screen.getByText('Tab 1').closest('[data-tab-id]')!;
|
|
fireEvent.mouseEnter(tab);
|
|
|
|
act(() => {
|
|
vi.advanceTimersByTime(450);
|
|
});
|
|
|
|
expect(screen.getByTestId('chevrons-right-icon')).toBeInTheDocument();
|
|
});
|
|
|
|
it('handles overlay menu on different tabs with proper move options', () => {
|
|
const tabs = [
|
|
createTab({ id: 'tab-1', name: 'Tab 1', agentSessionId: 'session-1' }),
|
|
createTab({ id: 'tab-2', name: 'Tab 2', agentSessionId: 'session-2' }),
|
|
createTab({ id: 'tab-3', name: 'Tab 3', agentSessionId: 'session-3' }),
|
|
];
|
|
|
|
render(
|
|
<TabBar
|
|
tabs={tabs}
|
|
activeTabId="tab-1"
|
|
theme={mockTheme}
|
|
onTabSelect={mockOnTabSelect}
|
|
onTabClose={mockOnTabClose}
|
|
onNewTab={mockOnNewTab}
|
|
onTabReorder={mockOnTabReorder}
|
|
/>
|
|
);
|
|
|
|
// Open overlay menu on Tab 1 (first tab)
|
|
const tab1 = screen.getByText('Tab 1').closest('[data-tab-id]')!;
|
|
fireEvent.mouseEnter(tab1);
|
|
|
|
act(() => {
|
|
vi.advanceTimersByTime(450);
|
|
});
|
|
|
|
// Move to First Position is hidden on first tab
|
|
expect(screen.queryByText('Move to First Position')).not.toBeInTheDocument();
|
|
// Move to Last Position is shown on first tab
|
|
expect(screen.getByText('Move to Last Position')).toBeInTheDocument();
|
|
|
|
// Close menu by hovering away
|
|
fireEvent.mouseLeave(tab1);
|
|
|
|
// Open overlay menu on Tab 3 (last tab)
|
|
const tab3 = screen.getByText('Tab 3').closest('[data-tab-id]')!;
|
|
fireEvent.mouseEnter(tab3);
|
|
|
|
act(() => {
|
|
vi.advanceTimersByTime(450);
|
|
});
|
|
|
|
// Move to Last Position is hidden on last tab
|
|
expect(screen.queryByText('Move to Last Position')).not.toBeInTheDocument();
|
|
// Move to First Position is shown on last tab
|
|
expect(screen.getByText('Move to First Position')).toBeInTheDocument();
|
|
});
|
|
|
|
it('overlay menu works with many tabs', () => {
|
|
const tabs = Array.from({ length: 20 }, (_, i) =>
|
|
createTab({ id: `tab-${i}`, name: `Tab ${i + 1}`, agentSessionId: `session-${i}` })
|
|
);
|
|
|
|
render(
|
|
<TabBar
|
|
tabs={tabs}
|
|
activeTabId="tab-10"
|
|
theme={mockTheme}
|
|
onTabSelect={mockOnTabSelect}
|
|
onTabClose={mockOnTabClose}
|
|
onNewTab={mockOnNewTab}
|
|
onTabReorder={mockOnTabReorder}
|
|
/>
|
|
);
|
|
|
|
const tab = screen.getByText('Tab 11').closest('[data-tab-id]')!;
|
|
fireEvent.mouseEnter(tab);
|
|
|
|
act(() => {
|
|
vi.advanceTimersByTime(450);
|
|
});
|
|
|
|
// Middle tab should show both move options
|
|
expect(screen.getByText('Move to First Position')).toBeInTheDocument();
|
|
expect(screen.getByText('Move to Last Position')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe('Send to Agent', () => {
|
|
const mockOnSendToAgent = vi.fn();
|
|
|
|
beforeEach(() => {
|
|
mockOnSendToAgent.mockClear();
|
|
});
|
|
|
|
it('shows Send to Agent button in hover overlay when onSendToAgent is provided', async () => {
|
|
const tabs = [
|
|
createTab({
|
|
id: 'tab-1',
|
|
name: 'Tab 1',
|
|
agentSessionId: 'abc123-def456',
|
|
}),
|
|
];
|
|
|
|
render(
|
|
<TabBar
|
|
tabs={tabs}
|
|
activeTabId="tab-1"
|
|
theme={mockTheme}
|
|
onTabSelect={mockOnTabSelect}
|
|
onTabClose={mockOnTabClose}
|
|
onNewTab={mockOnNewTab}
|
|
onSendToAgent={mockOnSendToAgent}
|
|
/>
|
|
);
|
|
|
|
const tab = screen.getByText('Tab 1').closest('[data-tab-id]')!;
|
|
fireEvent.mouseEnter(tab);
|
|
|
|
// Advance timers past the 400ms delay
|
|
act(() => {
|
|
vi.advanceTimersByTime(450);
|
|
});
|
|
|
|
// Send to Agent button should be visible
|
|
expect(screen.getByText('Context: Send to Agent')).toBeInTheDocument();
|
|
});
|
|
|
|
it('does not show Send to Agent button when onSendToAgent is not provided', async () => {
|
|
const tabs = [
|
|
createTab({
|
|
id: 'tab-1',
|
|
name: 'Tab 1',
|
|
agentSessionId: 'abc123-def456',
|
|
}),
|
|
];
|
|
|
|
render(
|
|
<TabBar
|
|
tabs={tabs}
|
|
activeTabId="tab-1"
|
|
theme={mockTheme}
|
|
onTabSelect={mockOnTabSelect}
|
|
onTabClose={mockOnTabClose}
|
|
onNewTab={mockOnNewTab}
|
|
/>
|
|
);
|
|
|
|
const tab = screen.getByText('Tab 1').closest('[data-tab-id]')!;
|
|
fireEvent.mouseEnter(tab);
|
|
|
|
act(() => {
|
|
vi.advanceTimersByTime(450);
|
|
});
|
|
|
|
// Send to Agent button should NOT be visible
|
|
expect(screen.queryByText('Context: Send to Agent')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('does not show Send to Agent button for tabs without agentSessionId', async () => {
|
|
const tabs = [
|
|
createTab({
|
|
id: 'tab-1',
|
|
name: '',
|
|
agentSessionId: undefined,
|
|
}),
|
|
];
|
|
|
|
render(
|
|
<TabBar
|
|
tabs={tabs}
|
|
activeTabId="tab-1"
|
|
theme={mockTheme}
|
|
onTabSelect={mockOnTabSelect}
|
|
onTabClose={mockOnTabClose}
|
|
onNewTab={mockOnNewTab}
|
|
onSendToAgent={mockOnSendToAgent}
|
|
/>
|
|
);
|
|
|
|
const tab = screen.getByText('New Session').closest('[data-tab-id]')!;
|
|
fireEvent.mouseEnter(tab);
|
|
|
|
act(() => {
|
|
vi.advanceTimersByTime(500);
|
|
});
|
|
|
|
// Overlay shouldn't be shown for tabs without agentSessionId
|
|
expect(screen.queryByText('Context: Send to Agent')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('calls onSendToAgent with tab id when Send to Agent button is clicked', async () => {
|
|
const tabs = [
|
|
createTab({
|
|
id: 'tab-1',
|
|
name: 'Tab 1',
|
|
agentSessionId: 'abc123-def456',
|
|
}),
|
|
];
|
|
|
|
render(
|
|
<TabBar
|
|
tabs={tabs}
|
|
activeTabId="tab-1"
|
|
theme={mockTheme}
|
|
onTabSelect={mockOnTabSelect}
|
|
onTabClose={mockOnTabClose}
|
|
onNewTab={mockOnNewTab}
|
|
onSendToAgent={mockOnSendToAgent}
|
|
/>
|
|
);
|
|
|
|
const tab = screen.getByText('Tab 1').closest('[data-tab-id]')!;
|
|
fireEvent.mouseEnter(tab);
|
|
|
|
act(() => {
|
|
vi.advanceTimersByTime(450);
|
|
});
|
|
|
|
const sendToAgentButton = screen.getByText('Context: Send to Agent');
|
|
fireEvent.click(sendToAgentButton);
|
|
|
|
expect(mockOnSendToAgent).toHaveBeenCalledWith('tab-1');
|
|
expect(mockOnSendToAgent).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('closes overlay after clicking Send to Agent', async () => {
|
|
const tabs = [
|
|
createTab({
|
|
id: 'tab-1',
|
|
name: 'Tab 1',
|
|
agentSessionId: 'abc123-def456',
|
|
}),
|
|
];
|
|
|
|
render(
|
|
<TabBar
|
|
tabs={tabs}
|
|
activeTabId="tab-1"
|
|
theme={mockTheme}
|
|
onTabSelect={mockOnTabSelect}
|
|
onTabClose={mockOnTabClose}
|
|
onNewTab={mockOnNewTab}
|
|
onSendToAgent={mockOnSendToAgent}
|
|
/>
|
|
);
|
|
|
|
const tab = screen.getByText('Tab 1').closest('[data-tab-id]')!;
|
|
fireEvent.mouseEnter(tab);
|
|
|
|
act(() => {
|
|
vi.advanceTimersByTime(450);
|
|
});
|
|
|
|
// Click Send to Agent
|
|
const sendToAgentButton = screen.getByText('Context: Send to Agent');
|
|
fireEvent.click(sendToAgentButton);
|
|
|
|
// Overlay should be closed
|
|
expect(screen.queryByText('Context: Send to Agent')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('renders ArrowRightCircle icon for Send to Agent button', async () => {
|
|
const tabs = [
|
|
createTab({
|
|
id: 'tab-1',
|
|
name: 'Tab 1',
|
|
agentSessionId: 'abc123-def456',
|
|
}),
|
|
];
|
|
|
|
render(
|
|
<TabBar
|
|
tabs={tabs}
|
|
activeTabId="tab-1"
|
|
theme={mockTheme}
|
|
onTabSelect={mockOnTabSelect}
|
|
onTabClose={mockOnTabClose}
|
|
onNewTab={mockOnNewTab}
|
|
onSendToAgent={mockOnSendToAgent}
|
|
/>
|
|
);
|
|
|
|
const tab = screen.getByText('Tab 1').closest('[data-tab-id]')!;
|
|
fireEvent.mouseEnter(tab);
|
|
|
|
act(() => {
|
|
vi.advanceTimersByTime(450);
|
|
});
|
|
|
|
// The ArrowRightCircle icon should be present
|
|
expect(screen.getByTestId('arrow-right-circle-icon')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe('Publish as GitHub Gist', () => {
|
|
const mockOnPublishGist = vi.fn();
|
|
|
|
beforeEach(() => {
|
|
mockOnPublishGist.mockClear();
|
|
});
|
|
|
|
it('shows Publish as GitHub Gist button when onPublishGist and ghCliAvailable are provided and tab has logs', async () => {
|
|
const tabs = [
|
|
createTab({
|
|
id: 'tab-1',
|
|
name: 'Tab 1',
|
|
agentSessionId: 'abc123-def456',
|
|
logs: [{ id: '1', timestamp: Date.now(), source: 'user', text: 'Hello' }],
|
|
}),
|
|
];
|
|
|
|
render(
|
|
<TabBar
|
|
tabs={tabs}
|
|
activeTabId="tab-1"
|
|
theme={mockTheme}
|
|
onTabSelect={mockOnTabSelect}
|
|
onTabClose={mockOnTabClose}
|
|
onNewTab={mockOnNewTab}
|
|
onPublishGist={mockOnPublishGist}
|
|
ghCliAvailable={true}
|
|
/>
|
|
);
|
|
|
|
const tab = screen.getByText('Tab 1').closest('[data-tab-id]')!;
|
|
fireEvent.mouseEnter(tab);
|
|
|
|
act(() => {
|
|
vi.advanceTimersByTime(450);
|
|
});
|
|
|
|
expect(screen.getByText('Context: Publish as GitHub Gist')).toBeInTheDocument();
|
|
});
|
|
|
|
it('does not show Publish as GitHub Gist button when ghCliAvailable is false', async () => {
|
|
const tabs = [
|
|
createTab({
|
|
id: 'tab-1',
|
|
name: 'Tab 1',
|
|
agentSessionId: 'abc123-def456',
|
|
logs: [{ id: '1', timestamp: Date.now(), source: 'user', text: 'Hello' }],
|
|
}),
|
|
];
|
|
|
|
render(
|
|
<TabBar
|
|
tabs={tabs}
|
|
activeTabId="tab-1"
|
|
theme={mockTheme}
|
|
onTabSelect={mockOnTabSelect}
|
|
onTabClose={mockOnTabClose}
|
|
onNewTab={mockOnNewTab}
|
|
onPublishGist={mockOnPublishGist}
|
|
ghCliAvailable={false}
|
|
/>
|
|
);
|
|
|
|
const tab = screen.getByText('Tab 1').closest('[data-tab-id]')!;
|
|
fireEvent.mouseEnter(tab);
|
|
|
|
act(() => {
|
|
vi.advanceTimersByTime(450);
|
|
});
|
|
|
|
expect(screen.queryByText('Context: Publish as GitHub Gist')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('does not show Publish as GitHub Gist button when tab has no logs', async () => {
|
|
const tabs = [
|
|
createTab({
|
|
id: 'tab-1',
|
|
name: 'Tab 1',
|
|
agentSessionId: 'abc123-def456',
|
|
logs: [],
|
|
}),
|
|
];
|
|
|
|
render(
|
|
<TabBar
|
|
tabs={tabs}
|
|
activeTabId="tab-1"
|
|
theme={mockTheme}
|
|
onTabSelect={mockOnTabSelect}
|
|
onTabClose={mockOnTabClose}
|
|
onNewTab={mockOnNewTab}
|
|
onPublishGist={mockOnPublishGist}
|
|
ghCliAvailable={true}
|
|
/>
|
|
);
|
|
|
|
const tab = screen.getByText('Tab 1').closest('[data-tab-id]')!;
|
|
fireEvent.mouseEnter(tab);
|
|
|
|
act(() => {
|
|
vi.advanceTimersByTime(450);
|
|
});
|
|
|
|
expect(screen.queryByText('Context: Publish as GitHub Gist')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('calls onPublishGist with tab id when clicked', async () => {
|
|
const tabs = [
|
|
createTab({
|
|
id: 'tab-1',
|
|
name: 'Tab 1',
|
|
agentSessionId: 'abc123-def456',
|
|
logs: [{ id: '1', timestamp: Date.now(), source: 'user', text: 'Hello' }],
|
|
}),
|
|
];
|
|
|
|
render(
|
|
<TabBar
|
|
tabs={tabs}
|
|
activeTabId="tab-1"
|
|
theme={mockTheme}
|
|
onTabSelect={mockOnTabSelect}
|
|
onTabClose={mockOnTabClose}
|
|
onNewTab={mockOnNewTab}
|
|
onPublishGist={mockOnPublishGist}
|
|
ghCliAvailable={true}
|
|
/>
|
|
);
|
|
|
|
const tab = screen.getByText('Tab 1').closest('[data-tab-id]')!;
|
|
fireEvent.mouseEnter(tab);
|
|
|
|
act(() => {
|
|
vi.advanceTimersByTime(450);
|
|
});
|
|
|
|
const publishGistButton = screen.getByText('Context: Publish as GitHub Gist');
|
|
fireEvent.click(publishGistButton);
|
|
|
|
expect(mockOnPublishGist).toHaveBeenCalledWith('tab-1');
|
|
expect(mockOnPublishGist).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('closes overlay after clicking Publish as GitHub Gist', async () => {
|
|
const tabs = [
|
|
createTab({
|
|
id: 'tab-1',
|
|
name: 'Tab 1',
|
|
agentSessionId: 'abc123-def456',
|
|
logs: [{ id: '1', timestamp: Date.now(), source: 'user', text: 'Hello' }],
|
|
}),
|
|
];
|
|
|
|
render(
|
|
<TabBar
|
|
tabs={tabs}
|
|
activeTabId="tab-1"
|
|
theme={mockTheme}
|
|
onTabSelect={mockOnTabSelect}
|
|
onTabClose={mockOnTabClose}
|
|
onNewTab={mockOnNewTab}
|
|
onPublishGist={mockOnPublishGist}
|
|
ghCliAvailable={true}
|
|
/>
|
|
);
|
|
|
|
const tab = screen.getByText('Tab 1').closest('[data-tab-id]')!;
|
|
fireEvent.mouseEnter(tab);
|
|
|
|
act(() => {
|
|
vi.advanceTimersByTime(450);
|
|
});
|
|
|
|
const publishGistButton = screen.getByText('Context: Publish as GitHub Gist');
|
|
fireEvent.click(publishGistButton);
|
|
|
|
expect(screen.queryByText('Context: Publish as GitHub Gist')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('renders Share2 icon for Publish as GitHub Gist button', async () => {
|
|
const tabs = [
|
|
createTab({
|
|
id: 'tab-1',
|
|
name: 'Tab 1',
|
|
agentSessionId: 'abc123-def456',
|
|
logs: [{ id: '1', timestamp: Date.now(), source: 'user', text: 'Hello' }],
|
|
}),
|
|
];
|
|
|
|
render(
|
|
<TabBar
|
|
tabs={tabs}
|
|
activeTabId="tab-1"
|
|
theme={mockTheme}
|
|
onTabSelect={mockOnTabSelect}
|
|
onTabClose={mockOnTabClose}
|
|
onNewTab={mockOnNewTab}
|
|
onPublishGist={mockOnPublishGist}
|
|
ghCliAvailable={true}
|
|
/>
|
|
);
|
|
|
|
const tab = screen.getByText('Tab 1').closest('[data-tab-id]')!;
|
|
fireEvent.mouseEnter(tab);
|
|
|
|
act(() => {
|
|
vi.advanceTimersByTime(450);
|
|
});
|
|
|
|
expect(screen.getByTestId('share2-icon')).toBeInTheDocument();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('FileTab overlay menu', () => {
|
|
const aiTab = createTab({ id: 'tab-1', name: 'AI Tab 1', agentSessionId: 'sess-1' });
|
|
const defaultTabs: AITab[] = [aiTab];
|
|
|
|
const fileTab: FilePreviewTab = {
|
|
id: 'file-tab-1',
|
|
path: '/path/to/document.md',
|
|
name: 'document',
|
|
extension: '.md',
|
|
content: '# Test Document\n\nThis is test content.',
|
|
scrollTop: 0,
|
|
searchQuery: '',
|
|
editMode: false,
|
|
editContent: undefined,
|
|
createdAt: Date.now(),
|
|
lastModified: Date.now(),
|
|
};
|
|
|
|
const unifiedTabs = [
|
|
{ type: 'ai' as const, id: 'tab-1', data: aiTab },
|
|
{ type: 'file' as const, id: 'file-tab-1', data: fileTab },
|
|
];
|
|
|
|
it('shows file overlay menu on hover after delay', async () => {
|
|
vi.useFakeTimers();
|
|
|
|
render(
|
|
<TabBar
|
|
tabs={defaultTabs}
|
|
activeTabId="tab-1"
|
|
theme={mockTheme}
|
|
onTabSelect={vi.fn()}
|
|
onTabClose={vi.fn()}
|
|
onNewTab={vi.fn()}
|
|
unifiedTabs={unifiedTabs}
|
|
activeFileTabId={null}
|
|
onFileTabSelect={vi.fn()}
|
|
onFileTabClose={vi.fn()}
|
|
/>
|
|
);
|
|
|
|
const fileTabElement = screen.getByText('document').closest('[data-tab-id="file-tab-1"]');
|
|
expect(fileTabElement).toBeInTheDocument();
|
|
|
|
// Hover over the file tab
|
|
await act(async () => {
|
|
fireEvent.mouseEnter(fileTabElement!);
|
|
});
|
|
|
|
// Overlay should not be visible immediately
|
|
expect(screen.queryByText('Copy File Path')).not.toBeInTheDocument();
|
|
|
|
// Wait for the delay
|
|
await act(async () => {
|
|
vi.advanceTimersByTime(450);
|
|
});
|
|
|
|
// Overlay should now be visible with file-specific actions
|
|
expect(screen.getByText('Copy File Path')).toBeInTheDocument();
|
|
expect(screen.getByText('Copy File Name')).toBeInTheDocument();
|
|
expect(screen.getByText('Open in Default App')).toBeInTheDocument();
|
|
expect(screen.getByText('Reveal in Finder')).toBeInTheDocument();
|
|
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it('shows file-specific actions in overlay menu', async () => {
|
|
vi.useFakeTimers();
|
|
|
|
render(
|
|
<TabBar
|
|
tabs={defaultTabs}
|
|
activeTabId="tab-1"
|
|
theme={mockTheme}
|
|
onTabSelect={vi.fn()}
|
|
onTabClose={vi.fn()}
|
|
onNewTab={vi.fn()}
|
|
unifiedTabs={unifiedTabs}
|
|
activeFileTabId={null}
|
|
onFileTabSelect={vi.fn()}
|
|
onFileTabClose={vi.fn()}
|
|
/>
|
|
);
|
|
|
|
const fileTabElement = screen.getByText('document').closest('[data-tab-id="file-tab-1"]');
|
|
|
|
await act(async () => {
|
|
fireEvent.mouseEnter(fileTabElement!);
|
|
vi.advanceTimersByTime(450);
|
|
});
|
|
|
|
// Should show file-specific actions (these are unique to file tabs)
|
|
expect(screen.getByText('Copy File Path')).toBeInTheDocument();
|
|
expect(screen.getByText('Open in Default App')).toBeInTheDocument();
|
|
expect(screen.getByText('Reveal in Finder')).toBeInTheDocument();
|
|
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it('copies file path to clipboard when clicking Copy File Path', async () => {
|
|
vi.useFakeTimers();
|
|
const mockWriteText = vi.fn().mockResolvedValue(undefined);
|
|
Object.defineProperty(navigator, 'clipboard', {
|
|
value: { writeText: mockWriteText },
|
|
writable: true,
|
|
});
|
|
|
|
render(
|
|
<TabBar
|
|
tabs={defaultTabs}
|
|
activeTabId="tab-1"
|
|
theme={mockTheme}
|
|
onTabSelect={vi.fn()}
|
|
onTabClose={vi.fn()}
|
|
onNewTab={vi.fn()}
|
|
unifiedTabs={unifiedTabs}
|
|
activeFileTabId={null}
|
|
onFileTabSelect={vi.fn()}
|
|
onFileTabClose={vi.fn()}
|
|
/>
|
|
);
|
|
|
|
const fileTabElement = screen.getByText('document').closest('[data-tab-id="file-tab-1"]');
|
|
|
|
await act(async () => {
|
|
fireEvent.mouseEnter(fileTabElement!);
|
|
vi.advanceTimersByTime(450);
|
|
});
|
|
|
|
const copyPathButton = screen.getByText('Copy File Path');
|
|
await act(async () => {
|
|
fireEvent.click(copyPathButton);
|
|
});
|
|
|
|
expect(mockWriteText).toHaveBeenCalledWith('/path/to/document.md');
|
|
expect(screen.getByText('Copied!')).toBeInTheDocument();
|
|
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it('copies filename with extension when clicking Copy File Name', async () => {
|
|
vi.useFakeTimers();
|
|
const mockWriteText = vi.fn().mockResolvedValue(undefined);
|
|
Object.defineProperty(navigator, 'clipboard', {
|
|
value: { writeText: mockWriteText },
|
|
writable: true,
|
|
});
|
|
|
|
render(
|
|
<TabBar
|
|
tabs={defaultTabs}
|
|
activeTabId="tab-1"
|
|
theme={mockTheme}
|
|
onTabSelect={vi.fn()}
|
|
onTabClose={vi.fn()}
|
|
onNewTab={vi.fn()}
|
|
unifiedTabs={unifiedTabs}
|
|
activeFileTabId={null}
|
|
onFileTabSelect={vi.fn()}
|
|
onFileTabClose={vi.fn()}
|
|
/>
|
|
);
|
|
|
|
const fileTabElement = screen.getByText('document').closest('[data-tab-id="file-tab-1"]');
|
|
|
|
await act(async () => {
|
|
fireEvent.mouseEnter(fileTabElement!);
|
|
vi.advanceTimersByTime(450);
|
|
});
|
|
|
|
const copyNameButton = screen.getByText('Copy File Name');
|
|
await act(async () => {
|
|
fireEvent.click(copyNameButton);
|
|
});
|
|
|
|
expect(mockWriteText).toHaveBeenCalledWith('document.md');
|
|
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it('calls openExternal when clicking Open in Default App', async () => {
|
|
vi.useFakeTimers();
|
|
const mockOpenExternal = vi.fn().mockResolvedValue(undefined);
|
|
window.maestro = {
|
|
...window.maestro,
|
|
shell: {
|
|
...window.maestro.shell,
|
|
openExternal: mockOpenExternal,
|
|
},
|
|
} as typeof window.maestro;
|
|
|
|
render(
|
|
<TabBar
|
|
tabs={defaultTabs}
|
|
activeTabId="tab-1"
|
|
theme={mockTheme}
|
|
onTabSelect={vi.fn()}
|
|
onTabClose={vi.fn()}
|
|
onNewTab={vi.fn()}
|
|
unifiedTabs={unifiedTabs}
|
|
activeFileTabId={null}
|
|
onFileTabSelect={vi.fn()}
|
|
onFileTabClose={vi.fn()}
|
|
/>
|
|
);
|
|
|
|
const fileTabElement = screen.getByText('document').closest('[data-tab-id="file-tab-1"]');
|
|
|
|
await act(async () => {
|
|
fireEvent.mouseEnter(fileTabElement!);
|
|
vi.advanceTimersByTime(450);
|
|
});
|
|
|
|
const openButton = screen.getByText('Open in Default App');
|
|
await act(async () => {
|
|
fireEvent.click(openButton);
|
|
});
|
|
|
|
expect(mockOpenExternal).toHaveBeenCalledWith('file:///path/to/document.md');
|
|
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it('calls showItemInFolder when clicking Reveal in Finder', async () => {
|
|
vi.useFakeTimers();
|
|
const mockShowItemInFolder = vi.fn().mockResolvedValue(undefined);
|
|
window.maestro = {
|
|
...window.maestro,
|
|
shell: {
|
|
...window.maestro.shell,
|
|
showItemInFolder: mockShowItemInFolder,
|
|
},
|
|
} as typeof window.maestro;
|
|
|
|
render(
|
|
<TabBar
|
|
tabs={defaultTabs}
|
|
activeTabId="tab-1"
|
|
theme={mockTheme}
|
|
onTabSelect={vi.fn()}
|
|
onTabClose={vi.fn()}
|
|
onNewTab={vi.fn()}
|
|
unifiedTabs={unifiedTabs}
|
|
activeFileTabId={null}
|
|
onFileTabSelect={vi.fn()}
|
|
onFileTabClose={vi.fn()}
|
|
/>
|
|
);
|
|
|
|
const fileTabElement = screen.getByText('document').closest('[data-tab-id="file-tab-1"]');
|
|
|
|
await act(async () => {
|
|
fireEvent.mouseEnter(fileTabElement!);
|
|
vi.advanceTimersByTime(450);
|
|
});
|
|
|
|
const revealButton = screen.getByText('Reveal in Finder');
|
|
await act(async () => {
|
|
fireEvent.click(revealButton);
|
|
});
|
|
|
|
expect(mockShowItemInFolder).toHaveBeenCalledWith('/path/to/document.md');
|
|
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it('shows Close Tab action and calls onFileTabClose when clicked', async () => {
|
|
vi.useFakeTimers();
|
|
const mockFileTabClose = vi.fn();
|
|
|
|
render(
|
|
<TabBar
|
|
tabs={defaultTabs}
|
|
activeTabId="tab-1"
|
|
theme={mockTheme}
|
|
onTabSelect={vi.fn()}
|
|
onTabClose={vi.fn()}
|
|
onNewTab={vi.fn()}
|
|
unifiedTabs={unifiedTabs}
|
|
activeFileTabId={null}
|
|
onFileTabSelect={vi.fn()}
|
|
onFileTabClose={mockFileTabClose}
|
|
/>
|
|
);
|
|
|
|
const fileTabElement = screen.getByText('document').closest('[data-tab-id="file-tab-1"]');
|
|
|
|
await act(async () => {
|
|
fireEvent.mouseEnter(fileTabElement!);
|
|
vi.advanceTimersByTime(450);
|
|
});
|
|
|
|
// Get all "Close Tab" buttons - find the one in the file tab overlay
|
|
// The overlay buttons are in a div with specific styling
|
|
const closeTabButtons = screen.getAllByText('Close Tab');
|
|
// The file tab's Close Tab button is in a standalone button (not the one with "X" icon prefix from AI tab overlay)
|
|
const closeButton = closeTabButtons.find((btn) =>
|
|
btn.closest('.shadow-xl')?.textContent?.includes('Copy File Path')
|
|
);
|
|
expect(closeButton).toBeTruthy();
|
|
|
|
await act(async () => {
|
|
fireEvent.click(closeButton!);
|
|
});
|
|
|
|
expect(mockFileTabClose).toHaveBeenCalledWith('file-tab-1');
|
|
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it('shows Close Other Tabs action and calls handler when clicked', async () => {
|
|
vi.useFakeTimers();
|
|
const mockCloseOtherTabs = vi.fn();
|
|
|
|
// Create multiple tabs to test Close Other Tabs
|
|
const fileTab2: FilePreviewTab = {
|
|
id: 'file-tab-2',
|
|
path: '/path/to/other.ts',
|
|
name: 'other',
|
|
extension: '.ts',
|
|
content: 'const y = 2;',
|
|
scrollTop: 0,
|
|
searchQuery: '',
|
|
editMode: false,
|
|
editContent: undefined,
|
|
createdAt: Date.now(),
|
|
lastModified: Date.now(),
|
|
};
|
|
|
|
const multiFileUnifiedTabs = [
|
|
{ type: 'ai' as const, id: 'tab-1', data: aiTab },
|
|
{ type: 'file' as const, id: 'file-tab-1', data: fileTab },
|
|
{ type: 'file' as const, id: 'file-tab-2', data: fileTab2 },
|
|
];
|
|
|
|
render(
|
|
<TabBar
|
|
tabs={defaultTabs}
|
|
activeTabId="tab-1"
|
|
theme={mockTheme}
|
|
onTabSelect={vi.fn()}
|
|
onTabClose={vi.fn()}
|
|
onNewTab={vi.fn()}
|
|
unifiedTabs={multiFileUnifiedTabs}
|
|
activeFileTabId={null}
|
|
onFileTabSelect={vi.fn()}
|
|
onFileTabClose={vi.fn()}
|
|
onCloseOtherTabs={mockCloseOtherTabs}
|
|
/>
|
|
);
|
|
|
|
const fileTabElement = screen.getByText('document').closest('[data-tab-id="file-tab-1"]');
|
|
|
|
await act(async () => {
|
|
fireEvent.mouseEnter(fileTabElement!);
|
|
vi.advanceTimersByTime(450);
|
|
});
|
|
|
|
// Should show Close Other Tabs option
|
|
const closeOtherButtons = screen.getAllByText('Close Other Tabs');
|
|
// Find the one in the file tab overlay (has Copy File Path action)
|
|
const closeOtherButton = closeOtherButtons.find((btn) =>
|
|
btn.closest('.shadow-xl')?.textContent?.includes('Copy File Path')
|
|
);
|
|
expect(closeOtherButton).toBeTruthy();
|
|
|
|
await act(async () => {
|
|
fireEvent.click(closeOtherButton!);
|
|
});
|
|
|
|
expect(mockCloseOtherTabs).toHaveBeenCalled();
|
|
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it('disables Close Other Tabs when only one tab exists', async () => {
|
|
vi.useFakeTimers();
|
|
const mockCloseOtherTabs = vi.fn();
|
|
|
|
// Single tab only
|
|
const singleTabUnified = [{ type: 'file' as const, id: 'file-tab-1', data: fileTab }];
|
|
|
|
render(
|
|
<TabBar
|
|
tabs={[]}
|
|
activeTabId=""
|
|
theme={mockTheme}
|
|
onTabSelect={vi.fn()}
|
|
onTabClose={vi.fn()}
|
|
onNewTab={vi.fn()}
|
|
unifiedTabs={singleTabUnified}
|
|
activeFileTabId="file-tab-1"
|
|
onFileTabSelect={vi.fn()}
|
|
onFileTabClose={vi.fn()}
|
|
onCloseOtherTabs={mockCloseOtherTabs}
|
|
/>
|
|
);
|
|
|
|
const fileTabElement = screen.getByText('document').closest('[data-tab-id="file-tab-1"]');
|
|
|
|
await act(async () => {
|
|
fireEvent.mouseEnter(fileTabElement!);
|
|
vi.advanceTimersByTime(450);
|
|
});
|
|
|
|
// Should show Close Other Tabs but disabled
|
|
const closeOtherButton = screen.getByText('Close Other Tabs');
|
|
expect(closeOtherButton).toBeInTheDocument();
|
|
expect(closeOtherButton.closest('button')).toHaveAttribute('disabled');
|
|
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it('shows Close Tabs to Left action and calls handler when clicked', async () => {
|
|
vi.useFakeTimers();
|
|
const mockCloseTabsLeft = vi.fn();
|
|
|
|
// Create multiple tabs - file tab in the middle
|
|
const fileTab2: FilePreviewTab = {
|
|
id: 'file-tab-2',
|
|
path: '/path/to/other.ts',
|
|
name: 'other',
|
|
extension: '.ts',
|
|
content: 'const y = 2;',
|
|
scrollTop: 0,
|
|
searchQuery: '',
|
|
editMode: false,
|
|
editContent: undefined,
|
|
createdAt: Date.now(),
|
|
lastModified: Date.now(),
|
|
};
|
|
|
|
// File tab is at index 1 (has tabs to the left)
|
|
const multiTabsUnified = [
|
|
{ type: 'ai' as const, id: 'tab-1', data: aiTab },
|
|
{ type: 'file' as const, id: 'file-tab-1', data: fileTab },
|
|
{ type: 'file' as const, id: 'file-tab-2', data: fileTab2 },
|
|
];
|
|
|
|
render(
|
|
<TabBar
|
|
tabs={defaultTabs}
|
|
activeTabId="tab-1"
|
|
theme={mockTheme}
|
|
onTabSelect={vi.fn()}
|
|
onTabClose={vi.fn()}
|
|
onNewTab={vi.fn()}
|
|
unifiedTabs={multiTabsUnified}
|
|
activeFileTabId={null}
|
|
onFileTabSelect={vi.fn()}
|
|
onFileTabClose={vi.fn()}
|
|
onCloseTabsLeft={mockCloseTabsLeft}
|
|
/>
|
|
);
|
|
|
|
// Hover over the middle tab (file-tab-1 at index 1)
|
|
const fileTabElement = screen.getByText('document').closest('[data-tab-id="file-tab-1"]');
|
|
|
|
await act(async () => {
|
|
fireEvent.mouseEnter(fileTabElement!);
|
|
vi.advanceTimersByTime(450);
|
|
});
|
|
|
|
// Should show Close Tabs to Left option
|
|
const closeLeftButtons = screen.getAllByText('Close Tabs to Left');
|
|
const closeLeftButton = closeLeftButtons.find((btn) =>
|
|
btn.closest('.shadow-xl')?.textContent?.includes('Copy File Path')
|
|
);
|
|
expect(closeLeftButton).toBeTruthy();
|
|
|
|
await act(async () => {
|
|
fireEvent.click(closeLeftButton!);
|
|
});
|
|
|
|
expect(mockCloseTabsLeft).toHaveBeenCalled();
|
|
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it('disables Close Tabs to Left for first tab', async () => {
|
|
vi.useFakeTimers();
|
|
const mockCloseTabsLeft = vi.fn();
|
|
|
|
// File tab is first
|
|
const fileFirstUnified = [
|
|
{ type: 'file' as const, id: 'file-tab-1', data: fileTab },
|
|
{ type: 'ai' as const, id: 'tab-1', data: aiTab },
|
|
];
|
|
|
|
render(
|
|
<TabBar
|
|
tabs={defaultTabs}
|
|
activeTabId="tab-1"
|
|
theme={mockTheme}
|
|
onTabSelect={vi.fn()}
|
|
onTabClose={vi.fn()}
|
|
onNewTab={vi.fn()}
|
|
unifiedTabs={fileFirstUnified}
|
|
activeFileTabId={null}
|
|
onFileTabSelect={vi.fn()}
|
|
onFileTabClose={vi.fn()}
|
|
onCloseTabsLeft={mockCloseTabsLeft}
|
|
/>
|
|
);
|
|
|
|
const fileTabElement = screen.getByText('document').closest('[data-tab-id="file-tab-1"]');
|
|
|
|
await act(async () => {
|
|
fireEvent.mouseEnter(fileTabElement!);
|
|
vi.advanceTimersByTime(450);
|
|
});
|
|
|
|
// Should show Close Tabs to Left but disabled (first tab)
|
|
const closeLeftButton = screen.getByText('Close Tabs to Left');
|
|
expect(closeLeftButton).toBeInTheDocument();
|
|
expect(closeLeftButton.closest('button')).toHaveAttribute('disabled');
|
|
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it('shows Close Tabs to Right action and calls handler when clicked', async () => {
|
|
vi.useFakeTimers();
|
|
const mockCloseTabsRight = vi.fn();
|
|
|
|
// Create multiple tabs - file tab in the middle
|
|
const fileTab2: FilePreviewTab = {
|
|
id: 'file-tab-2',
|
|
path: '/path/to/other.ts',
|
|
name: 'other',
|
|
extension: '.ts',
|
|
content: 'const y = 2;',
|
|
scrollTop: 0,
|
|
searchQuery: '',
|
|
editMode: false,
|
|
editContent: undefined,
|
|
createdAt: Date.now(),
|
|
lastModified: Date.now(),
|
|
};
|
|
|
|
// File tab is at index 1 (has tabs to the right)
|
|
const multiTabsUnified = [
|
|
{ type: 'ai' as const, id: 'tab-1', data: aiTab },
|
|
{ type: 'file' as const, id: 'file-tab-1', data: fileTab },
|
|
{ type: 'file' as const, id: 'file-tab-2', data: fileTab2 },
|
|
];
|
|
|
|
render(
|
|
<TabBar
|
|
tabs={defaultTabs}
|
|
activeTabId="tab-1"
|
|
theme={mockTheme}
|
|
onTabSelect={vi.fn()}
|
|
onTabClose={vi.fn()}
|
|
onNewTab={vi.fn()}
|
|
unifiedTabs={multiTabsUnified}
|
|
activeFileTabId={null}
|
|
onFileTabSelect={vi.fn()}
|
|
onFileTabClose={vi.fn()}
|
|
onCloseTabsRight={mockCloseTabsRight}
|
|
/>
|
|
);
|
|
|
|
// Hover over the middle tab (file-tab-1 at index 1)
|
|
const fileTabElement = screen.getByText('document').closest('[data-tab-id="file-tab-1"]');
|
|
|
|
await act(async () => {
|
|
fireEvent.mouseEnter(fileTabElement!);
|
|
vi.advanceTimersByTime(450);
|
|
});
|
|
|
|
// Should show Close Tabs to Right option
|
|
const closeRightButtons = screen.getAllByText('Close Tabs to Right');
|
|
const closeRightButton = closeRightButtons.find((btn) =>
|
|
btn.closest('.shadow-xl')?.textContent?.includes('Copy File Path')
|
|
);
|
|
expect(closeRightButton).toBeTruthy();
|
|
|
|
await act(async () => {
|
|
fireEvent.click(closeRightButton!);
|
|
});
|
|
|
|
expect(mockCloseTabsRight).toHaveBeenCalled();
|
|
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it('disables Close Tabs to Right for last tab', async () => {
|
|
vi.useFakeTimers();
|
|
const mockCloseTabsRight = vi.fn();
|
|
|
|
// File tab is last
|
|
const fileLastUnified = [
|
|
{ type: 'ai' as const, id: 'tab-1', data: aiTab },
|
|
{ type: 'file' as const, id: 'file-tab-1', data: fileTab },
|
|
];
|
|
|
|
render(
|
|
<TabBar
|
|
tabs={defaultTabs}
|
|
activeTabId="tab-1"
|
|
theme={mockTheme}
|
|
onTabSelect={vi.fn()}
|
|
onTabClose={vi.fn()}
|
|
onNewTab={vi.fn()}
|
|
unifiedTabs={fileLastUnified}
|
|
activeFileTabId={null}
|
|
onFileTabSelect={vi.fn()}
|
|
onFileTabClose={vi.fn()}
|
|
onCloseTabsRight={mockCloseTabsRight}
|
|
/>
|
|
);
|
|
|
|
const fileTabElement = screen.getByText('document').closest('[data-tab-id="file-tab-1"]');
|
|
|
|
await act(async () => {
|
|
fireEvent.mouseEnter(fileTabElement!);
|
|
vi.advanceTimersByTime(450);
|
|
});
|
|
|
|
// Should show Close Tabs to Right but disabled (last tab)
|
|
const closeRightButton = screen.getByText('Close Tabs to Right');
|
|
expect(closeRightButton).toBeInTheDocument();
|
|
expect(closeRightButton.closest('button')).toHaveAttribute('disabled');
|
|
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it('shows Move to First Position for non-first file tabs', async () => {
|
|
vi.useFakeTimers();
|
|
const mockUnifiedReorder = vi.fn();
|
|
|
|
// Put file tab in second position
|
|
const unifiedTabsWithFileSecond = [
|
|
{ type: 'ai' as const, id: 'tab-1', data: aiTab },
|
|
{ type: 'file' as const, id: 'file-tab-1', data: fileTab },
|
|
];
|
|
|
|
render(
|
|
<TabBar
|
|
tabs={defaultTabs}
|
|
activeTabId="tab-1"
|
|
theme={mockTheme}
|
|
onTabSelect={vi.fn()}
|
|
onTabClose={vi.fn()}
|
|
onNewTab={vi.fn()}
|
|
unifiedTabs={unifiedTabsWithFileSecond}
|
|
activeFileTabId={null}
|
|
onFileTabSelect={vi.fn()}
|
|
onFileTabClose={vi.fn()}
|
|
onUnifiedTabReorder={mockUnifiedReorder}
|
|
/>
|
|
);
|
|
|
|
const fileTabElement = screen.getByText('document').closest('[data-tab-id="file-tab-1"]');
|
|
|
|
await act(async () => {
|
|
fireEvent.mouseEnter(fileTabElement!);
|
|
vi.advanceTimersByTime(450);
|
|
});
|
|
|
|
// Should show Move to First Position
|
|
expect(screen.getByText('Move to First Position')).toBeInTheDocument();
|
|
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it('hides Move to First Position for first file tab', async () => {
|
|
vi.useFakeTimers();
|
|
const mockUnifiedReorder = vi.fn();
|
|
|
|
// Put file tab in first position
|
|
const unifiedTabsWithFileFirst = [
|
|
{ type: 'file' as const, id: 'file-tab-1', data: fileTab },
|
|
{ type: 'ai' as const, id: 'tab-1', data: aiTab },
|
|
];
|
|
|
|
render(
|
|
<TabBar
|
|
tabs={defaultTabs}
|
|
activeTabId="tab-1"
|
|
theme={mockTheme}
|
|
onTabSelect={vi.fn()}
|
|
onTabClose={vi.fn()}
|
|
onNewTab={vi.fn()}
|
|
unifiedTabs={unifiedTabsWithFileFirst}
|
|
activeFileTabId={null}
|
|
onFileTabSelect={vi.fn()}
|
|
onFileTabClose={vi.fn()}
|
|
onUnifiedTabReorder={mockUnifiedReorder}
|
|
/>
|
|
);
|
|
|
|
const fileTabElement = screen.getByText('document').closest('[data-tab-id="file-tab-1"]');
|
|
|
|
await act(async () => {
|
|
fireEvent.mouseEnter(fileTabElement!);
|
|
vi.advanceTimersByTime(450);
|
|
});
|
|
|
|
// Should NOT show Move to First Position
|
|
expect(screen.queryByText('Move to First Position')).not.toBeInTheDocument();
|
|
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it('closes overlay when mouse leaves', async () => {
|
|
vi.useFakeTimers();
|
|
|
|
render(
|
|
<TabBar
|
|
tabs={defaultTabs}
|
|
activeTabId="tab-1"
|
|
theme={mockTheme}
|
|
onTabSelect={vi.fn()}
|
|
onTabClose={vi.fn()}
|
|
onNewTab={vi.fn()}
|
|
unifiedTabs={unifiedTabs}
|
|
activeFileTabId={null}
|
|
onFileTabSelect={vi.fn()}
|
|
onFileTabClose={vi.fn()}
|
|
/>
|
|
);
|
|
|
|
const fileTabElement = screen.getByText('document').closest('[data-tab-id="file-tab-1"]');
|
|
|
|
// Hover to open overlay
|
|
await act(async () => {
|
|
fireEvent.mouseEnter(fileTabElement!);
|
|
vi.advanceTimersByTime(450);
|
|
});
|
|
|
|
expect(screen.getByText('Copy File Path')).toBeInTheDocument();
|
|
|
|
// Mouse leave from tab
|
|
await act(async () => {
|
|
fireEvent.mouseLeave(fileTabElement!);
|
|
vi.advanceTimersByTime(150); // Wait for close delay
|
|
});
|
|
|
|
// Overlay should be closed
|
|
expect(screen.queryByText('Copy File Path')).not.toBeInTheDocument();
|
|
|
|
vi.useRealTimers();
|
|
});
|
|
});
|
|
|
|
describe('Unified tabs drag and drop', () => {
|
|
const mockOnUnifiedTabReorder = vi.fn();
|
|
const mockOnTabReorder = vi.fn();
|
|
const mockOnFileTabSelect = vi.fn();
|
|
const mockOnFileTabClose = vi.fn();
|
|
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
vi.clearAllMocks();
|
|
Element.prototype.scrollTo = vi.fn();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
const aiTab1 = createTab({ id: 'ai-tab-1', name: 'AI Tab 1', agentSessionId: 'sess-1' });
|
|
const aiTab2 = createTab({ id: 'ai-tab-2', name: 'AI Tab 2', agentSessionId: 'sess-2' });
|
|
const aiTabs: AITab[] = [aiTab1, aiTab2];
|
|
|
|
const fileTab1: FilePreviewTab = {
|
|
id: 'file-tab-1',
|
|
path: '/path/to/file1.ts',
|
|
name: 'file1',
|
|
extension: '.ts',
|
|
content: 'const x = 1;',
|
|
scrollTop: 0,
|
|
searchQuery: '',
|
|
editMode: false,
|
|
editContent: undefined,
|
|
createdAt: Date.now(),
|
|
lastModified: Date.now(),
|
|
};
|
|
|
|
const fileTab2: FilePreviewTab = {
|
|
id: 'file-tab-2',
|
|
path: '/path/to/file2.md',
|
|
name: 'file2',
|
|
extension: '.md',
|
|
content: '# File 2',
|
|
scrollTop: 0,
|
|
searchQuery: '',
|
|
editMode: false,
|
|
editContent: undefined,
|
|
createdAt: Date.now() + 1,
|
|
lastModified: Date.now() + 1,
|
|
};
|
|
|
|
// Unified tabs: AI, File, AI, File
|
|
const unifiedTabs = [
|
|
{ type: 'ai' as const, id: 'ai-tab-1', data: aiTab1 },
|
|
{ type: 'file' as const, id: 'file-tab-1', data: fileTab1 },
|
|
{ type: 'ai' as const, id: 'ai-tab-2', data: aiTab2 },
|
|
{ type: 'file' as const, id: 'file-tab-2', data: fileTab2 },
|
|
];
|
|
|
|
it('drags AI tab to file tab position and calls onUnifiedTabReorder', () => {
|
|
render(
|
|
<TabBar
|
|
tabs={aiTabs}
|
|
activeTabId="ai-tab-1"
|
|
theme={mockTheme}
|
|
onTabSelect={vi.fn()}
|
|
onTabClose={vi.fn()}
|
|
onNewTab={vi.fn()}
|
|
onTabReorder={mockOnTabReorder}
|
|
onUnifiedTabReorder={mockOnUnifiedTabReorder}
|
|
unifiedTabs={unifiedTabs}
|
|
activeFileTabId={null}
|
|
onFileTabSelect={mockOnFileTabSelect}
|
|
onFileTabClose={mockOnFileTabClose}
|
|
/>
|
|
);
|
|
|
|
const aiTabElement = screen.getByText('AI Tab 1').closest('[data-tab-id]')!;
|
|
const fileTabElement = screen.getByText('file1').closest('[data-tab-id]')!;
|
|
|
|
// Start dragging ai-tab-1
|
|
fireEvent.dragStart(aiTabElement, {
|
|
dataTransfer: {
|
|
effectAllowed: '',
|
|
setData: vi.fn(),
|
|
getData: vi.fn().mockReturnValue('ai-tab-1'),
|
|
},
|
|
});
|
|
|
|
// Drop on file-tab-1
|
|
fireEvent.drop(fileTabElement, {
|
|
dataTransfer: {
|
|
getData: vi.fn().mockReturnValue('ai-tab-1'),
|
|
},
|
|
});
|
|
|
|
// Should call onUnifiedTabReorder with indices in unified array (0 to 1)
|
|
expect(mockOnUnifiedTabReorder).toHaveBeenCalledWith(0, 1);
|
|
// Should NOT call legacy onTabReorder since unified is available
|
|
expect(mockOnTabReorder).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('drags file tab to AI tab position and calls onUnifiedTabReorder', () => {
|
|
render(
|
|
<TabBar
|
|
tabs={aiTabs}
|
|
activeTabId="ai-tab-1"
|
|
theme={mockTheme}
|
|
onTabSelect={vi.fn()}
|
|
onTabClose={vi.fn()}
|
|
onNewTab={vi.fn()}
|
|
onTabReorder={mockOnTabReorder}
|
|
onUnifiedTabReorder={mockOnUnifiedTabReorder}
|
|
unifiedTabs={unifiedTabs}
|
|
activeFileTabId={null}
|
|
onFileTabSelect={mockOnFileTabSelect}
|
|
onFileTabClose={mockOnFileTabClose}
|
|
/>
|
|
);
|
|
|
|
const fileTabElement = screen.getByText('file1').closest('[data-tab-id]')!;
|
|
const aiTabElement = screen.getByText('AI Tab 2').closest('[data-tab-id]')!;
|
|
|
|
// Start dragging file-tab-1 (index 1)
|
|
fireEvent.dragStart(fileTabElement, {
|
|
dataTransfer: {
|
|
effectAllowed: '',
|
|
setData: vi.fn(),
|
|
getData: vi.fn().mockReturnValue('file-tab-1'),
|
|
},
|
|
});
|
|
|
|
// Drop on ai-tab-2 (index 2)
|
|
fireEvent.drop(aiTabElement, {
|
|
dataTransfer: {
|
|
getData: vi.fn().mockReturnValue('file-tab-1'),
|
|
},
|
|
});
|
|
|
|
// Should call onUnifiedTabReorder (from index 1 to index 2)
|
|
expect(mockOnUnifiedTabReorder).toHaveBeenCalledWith(1, 2);
|
|
});
|
|
|
|
it('drags file tab to another file tab position', () => {
|
|
render(
|
|
<TabBar
|
|
tabs={aiTabs}
|
|
activeTabId="ai-tab-1"
|
|
theme={mockTheme}
|
|
onTabSelect={vi.fn()}
|
|
onTabClose={vi.fn()}
|
|
onNewTab={vi.fn()}
|
|
onTabReorder={mockOnTabReorder}
|
|
onUnifiedTabReorder={mockOnUnifiedTabReorder}
|
|
unifiedTabs={unifiedTabs}
|
|
activeFileTabId={null}
|
|
onFileTabSelect={mockOnFileTabSelect}
|
|
onFileTabClose={mockOnFileTabClose}
|
|
/>
|
|
);
|
|
|
|
const fileTab1Element = screen.getByText('file1').closest('[data-tab-id]')!;
|
|
const fileTab2Element = screen.getByText('file2').closest('[data-tab-id]')!;
|
|
|
|
// Start dragging file-tab-1 (index 1)
|
|
fireEvent.dragStart(fileTab1Element, {
|
|
dataTransfer: {
|
|
effectAllowed: '',
|
|
setData: vi.fn(),
|
|
getData: vi.fn().mockReturnValue('file-tab-1'),
|
|
},
|
|
});
|
|
|
|
// Drop on file-tab-2 (index 3)
|
|
fireEvent.drop(fileTab2Element, {
|
|
dataTransfer: {
|
|
getData: vi.fn().mockReturnValue('file-tab-1'),
|
|
},
|
|
});
|
|
|
|
// Should call onUnifiedTabReorder (from index 1 to index 3)
|
|
expect(mockOnUnifiedTabReorder).toHaveBeenCalledWith(1, 3);
|
|
});
|
|
|
|
it('does not reorder when dropping on the same tab', () => {
|
|
render(
|
|
<TabBar
|
|
tabs={aiTabs}
|
|
activeTabId="ai-tab-1"
|
|
theme={mockTheme}
|
|
onTabSelect={vi.fn()}
|
|
onTabClose={vi.fn()}
|
|
onNewTab={vi.fn()}
|
|
onUnifiedTabReorder={mockOnUnifiedTabReorder}
|
|
unifiedTabs={unifiedTabs}
|
|
activeFileTabId={null}
|
|
onFileTabSelect={mockOnFileTabSelect}
|
|
onFileTabClose={mockOnFileTabClose}
|
|
/>
|
|
);
|
|
|
|
const fileTabElement = screen.getByText('file1').closest('[data-tab-id]')!;
|
|
|
|
// Drop on same tab
|
|
fireEvent.drop(fileTabElement, {
|
|
dataTransfer: {
|
|
getData: vi.fn().mockReturnValue('file-tab-1'),
|
|
},
|
|
});
|
|
|
|
expect(mockOnUnifiedTabReorder).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('sets drag over visual feedback on target tab', () => {
|
|
render(
|
|
<TabBar
|
|
tabs={aiTabs}
|
|
activeTabId="ai-tab-1"
|
|
theme={mockTheme}
|
|
onTabSelect={vi.fn()}
|
|
onTabClose={vi.fn()}
|
|
onNewTab={vi.fn()}
|
|
onUnifiedTabReorder={mockOnUnifiedTabReorder}
|
|
unifiedTabs={unifiedTabs}
|
|
activeFileTabId={null}
|
|
onFileTabSelect={mockOnFileTabSelect}
|
|
onFileTabClose={mockOnFileTabClose}
|
|
/>
|
|
);
|
|
|
|
const aiTabElement = screen.getByText('AI Tab 1').closest('[data-tab-id]')!;
|
|
const fileTabElement = screen.getByText('file1').closest('[data-tab-id]')!;
|
|
|
|
// Start dragging AI tab
|
|
fireEvent.dragStart(aiTabElement, {
|
|
dataTransfer: {
|
|
effectAllowed: '',
|
|
setData: vi.fn(),
|
|
getData: vi.fn().mockReturnValue('ai-tab-1'),
|
|
},
|
|
});
|
|
|
|
// Drag over file tab
|
|
fireEvent.dragOver(fileTabElement, {
|
|
dataTransfer: {
|
|
dropEffect: '',
|
|
},
|
|
});
|
|
|
|
// File tab should have ring visual
|
|
expect(fileTabElement).toHaveClass('ring-2');
|
|
});
|
|
|
|
it('uses legacy onTabReorder when unifiedTabs is not provided', () => {
|
|
render(
|
|
<TabBar
|
|
tabs={aiTabs}
|
|
activeTabId="ai-tab-1"
|
|
theme={mockTheme}
|
|
onTabSelect={vi.fn()}
|
|
onTabClose={vi.fn()}
|
|
onNewTab={vi.fn()}
|
|
onTabReorder={mockOnTabReorder}
|
|
onUnifiedTabReorder={mockOnUnifiedTabReorder}
|
|
// No unifiedTabs provided - should fall back to legacy behavior
|
|
/>
|
|
);
|
|
|
|
const tab1 = screen.getByText('AI Tab 1').closest('[data-tab-id]')!;
|
|
const tab2 = screen.getByText('AI Tab 2').closest('[data-tab-id]')!;
|
|
|
|
// Start dragging tab-1
|
|
fireEvent.dragStart(tab1, {
|
|
dataTransfer: {
|
|
effectAllowed: '',
|
|
setData: vi.fn(),
|
|
getData: vi.fn().mockReturnValue('ai-tab-1'),
|
|
},
|
|
});
|
|
|
|
// Drop on tab-2
|
|
fireEvent.drop(tab2, {
|
|
dataTransfer: {
|
|
getData: vi.fn().mockReturnValue('ai-tab-1'),
|
|
},
|
|
});
|
|
|
|
// Should use legacy onTabReorder
|
|
expect(mockOnTabReorder).toHaveBeenCalledWith(0, 1);
|
|
// Should NOT call onUnifiedTabReorder
|
|
expect(mockOnUnifiedTabReorder).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('shows Move to First/Last for file tabs when not at edges', async () => {
|
|
render(
|
|
<TabBar
|
|
tabs={aiTabs}
|
|
activeTabId="ai-tab-1"
|
|
theme={mockTheme}
|
|
onTabSelect={vi.fn()}
|
|
onTabClose={vi.fn()}
|
|
onNewTab={vi.fn()}
|
|
onUnifiedTabReorder={mockOnUnifiedTabReorder}
|
|
unifiedTabs={unifiedTabs}
|
|
activeFileTabId={null}
|
|
onFileTabSelect={mockOnFileTabSelect}
|
|
onFileTabClose={mockOnFileTabClose}
|
|
/>
|
|
);
|
|
|
|
// Hover over file1 (index 1, not first or last)
|
|
const fileTabElement = screen.getByText('file1').closest('[data-tab-id]')!;
|
|
|
|
await act(async () => {
|
|
fireEvent.mouseEnter(fileTabElement);
|
|
vi.advanceTimersByTime(450);
|
|
});
|
|
|
|
// Should show both move options
|
|
expect(screen.getByText('Move to First Position')).toBeInTheDocument();
|
|
expect(screen.getByText('Move to Last Position')).toBeInTheDocument();
|
|
});
|
|
|
|
it('hides Move to First for first tab', async () => {
|
|
render(
|
|
<TabBar
|
|
tabs={aiTabs}
|
|
activeTabId="ai-tab-1"
|
|
theme={mockTheme}
|
|
onTabSelect={vi.fn()}
|
|
onTabClose={vi.fn()}
|
|
onNewTab={vi.fn()}
|
|
onUnifiedTabReorder={mockOnUnifiedTabReorder}
|
|
unifiedTabs={unifiedTabs}
|
|
activeFileTabId={null}
|
|
onFileTabSelect={mockOnFileTabSelect}
|
|
onFileTabClose={mockOnFileTabClose}
|
|
/>
|
|
);
|
|
|
|
// Hover over AI Tab 1 (index 0, first tab)
|
|
const aiTabElement = screen.getByText('AI Tab 1').closest('[data-tab-id]')!;
|
|
|
|
await act(async () => {
|
|
fireEvent.mouseEnter(aiTabElement);
|
|
vi.advanceTimersByTime(450);
|
|
});
|
|
|
|
// Move to First should be hidden (not just disabled)
|
|
expect(screen.queryByText('Move to First Position')).not.toBeInTheDocument();
|
|
// Move to Last should be visible
|
|
expect(screen.getByText('Move to Last Position')).toBeInTheDocument();
|
|
});
|
|
|
|
it('hides Move to Last for last tab', async () => {
|
|
render(
|
|
<TabBar
|
|
tabs={aiTabs}
|
|
activeTabId="ai-tab-1"
|
|
theme={mockTheme}
|
|
onTabSelect={vi.fn()}
|
|
onTabClose={vi.fn()}
|
|
onNewTab={vi.fn()}
|
|
onUnifiedTabReorder={mockOnUnifiedTabReorder}
|
|
unifiedTabs={unifiedTabs}
|
|
activeFileTabId={null}
|
|
onFileTabSelect={mockOnFileTabSelect}
|
|
onFileTabClose={mockOnFileTabClose}
|
|
/>
|
|
);
|
|
|
|
// Hover over file2 (index 3, last tab)
|
|
const fileTabElement = screen.getByText('file2').closest('[data-tab-id]')!;
|
|
|
|
await act(async () => {
|
|
fireEvent.mouseEnter(fileTabElement);
|
|
vi.advanceTimersByTime(450);
|
|
});
|
|
|
|
// Move to First should be visible
|
|
expect(screen.getByText('Move to First Position')).toBeInTheDocument();
|
|
// Move to Last should be hidden (not just disabled)
|
|
expect(screen.queryByText('Move to Last Position')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('calls onUnifiedTabReorder when Move to First is clicked on file tab', async () => {
|
|
render(
|
|
<TabBar
|
|
tabs={aiTabs}
|
|
activeTabId="ai-tab-1"
|
|
theme={mockTheme}
|
|
onTabSelect={vi.fn()}
|
|
onTabClose={vi.fn()}
|
|
onNewTab={vi.fn()}
|
|
onUnifiedTabReorder={mockOnUnifiedTabReorder}
|
|
unifiedTabs={unifiedTabs}
|
|
activeFileTabId={null}
|
|
onFileTabSelect={mockOnFileTabSelect}
|
|
onFileTabClose={mockOnFileTabClose}
|
|
/>
|
|
);
|
|
|
|
// Hover over file1 (index 1)
|
|
const fileTabElement = screen.getByText('file1').closest('[data-tab-id]')!;
|
|
|
|
await act(async () => {
|
|
fireEvent.mouseEnter(fileTabElement);
|
|
vi.advanceTimersByTime(450);
|
|
});
|
|
|
|
// Click Move to First
|
|
const moveButton = screen.getByText('Move to First Position');
|
|
fireEvent.click(moveButton);
|
|
|
|
// Should call onUnifiedTabReorder with index 1 -> 0
|
|
expect(mockOnUnifiedTabReorder).toHaveBeenCalledWith(1, 0);
|
|
});
|
|
|
|
it('calls onUnifiedTabReorder when Move to Last is clicked on file tab', async () => {
|
|
render(
|
|
<TabBar
|
|
tabs={aiTabs}
|
|
activeTabId="ai-tab-1"
|
|
theme={mockTheme}
|
|
onTabSelect={vi.fn()}
|
|
onTabClose={vi.fn()}
|
|
onNewTab={vi.fn()}
|
|
onUnifiedTabReorder={mockOnUnifiedTabReorder}
|
|
unifiedTabs={unifiedTabs}
|
|
activeFileTabId={null}
|
|
onFileTabSelect={mockOnFileTabSelect}
|
|
onFileTabClose={mockOnFileTabClose}
|
|
/>
|
|
);
|
|
|
|
// Hover over file1 (index 1)
|
|
const fileTabElement = screen.getByText('file1').closest('[data-tab-id]')!;
|
|
|
|
await act(async () => {
|
|
fireEvent.mouseEnter(fileTabElement);
|
|
vi.advanceTimersByTime(450);
|
|
});
|
|
|
|
// Click Move to Last
|
|
const moveButton = screen.getByText('Move to Last Position');
|
|
fireEvent.click(moveButton);
|
|
|
|
// Should call onUnifiedTabReorder with index 1 -> 3 (last index)
|
|
expect(mockOnUnifiedTabReorder).toHaveBeenCalledWith(1, 3);
|
|
});
|
|
|
|
it('middle-click closes file tab', () => {
|
|
render(
|
|
<TabBar
|
|
tabs={aiTabs}
|
|
activeTabId="ai-tab-1"
|
|
theme={mockTheme}
|
|
onTabSelect={vi.fn()}
|
|
onTabClose={vi.fn()}
|
|
onNewTab={vi.fn()}
|
|
onUnifiedTabReorder={mockOnUnifiedTabReorder}
|
|
unifiedTabs={unifiedTabs}
|
|
activeFileTabId={null}
|
|
onFileTabSelect={mockOnFileTabSelect}
|
|
onFileTabClose={mockOnFileTabClose}
|
|
/>
|
|
);
|
|
|
|
const fileTabElement = screen.getByText('file1').closest('[data-tab-id]')!;
|
|
|
|
// Middle-click on file tab
|
|
fireEvent.mouseDown(fileTabElement, { button: 1 });
|
|
|
|
expect(mockOnFileTabClose).toHaveBeenCalledWith('file-tab-1');
|
|
});
|
|
|
|
it('left-click does NOT close file tab', () => {
|
|
render(
|
|
<TabBar
|
|
tabs={aiTabs}
|
|
activeTabId="ai-tab-1"
|
|
theme={mockTheme}
|
|
onTabSelect={vi.fn()}
|
|
onTabClose={vi.fn()}
|
|
onNewTab={vi.fn()}
|
|
onUnifiedTabReorder={mockOnUnifiedTabReorder}
|
|
unifiedTabs={unifiedTabs}
|
|
activeFileTabId={null}
|
|
onFileTabSelect={mockOnFileTabSelect}
|
|
onFileTabClose={mockOnFileTabClose}
|
|
/>
|
|
);
|
|
|
|
const fileTabElement = screen.getByText('file1').closest('[data-tab-id]')!;
|
|
|
|
// Left-click on file tab (button: 0)
|
|
fireEvent.mouseDown(fileTabElement, { button: 0 });
|
|
|
|
// Should NOT close the tab
|
|
expect(mockOnFileTabClose).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('right-click does NOT close file tab', () => {
|
|
render(
|
|
<TabBar
|
|
tabs={aiTabs}
|
|
activeTabId="ai-tab-1"
|
|
theme={mockTheme}
|
|
onTabSelect={vi.fn()}
|
|
onTabClose={vi.fn()}
|
|
onNewTab={vi.fn()}
|
|
onUnifiedTabReorder={mockOnUnifiedTabReorder}
|
|
unifiedTabs={unifiedTabs}
|
|
activeFileTabId={null}
|
|
onFileTabSelect={mockOnFileTabSelect}
|
|
onFileTabClose={mockOnFileTabClose}
|
|
/>
|
|
);
|
|
|
|
const fileTabElement = screen.getByText('file1').closest('[data-tab-id]')!;
|
|
|
|
// Right-click on file tab (button: 2)
|
|
fireEvent.mouseDown(fileTabElement, { button: 2 });
|
|
|
|
// Should NOT close the tab
|
|
expect(mockOnFileTabClose).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('middle-click on AI tab still works in unified mode', () => {
|
|
const mockOnAiTabClose = vi.fn();
|
|
|
|
render(
|
|
<TabBar
|
|
tabs={aiTabs}
|
|
activeTabId="ai-tab-1"
|
|
theme={mockTheme}
|
|
onTabSelect={vi.fn()}
|
|
onTabClose={mockOnAiTabClose}
|
|
onNewTab={vi.fn()}
|
|
onUnifiedTabReorder={mockOnUnifiedTabReorder}
|
|
unifiedTabs={unifiedTabs}
|
|
activeFileTabId={null}
|
|
onFileTabSelect={mockOnFileTabSelect}
|
|
onFileTabClose={mockOnFileTabClose}
|
|
/>
|
|
);
|
|
|
|
const aiTabElement = screen.getByText('AI Tab 1').closest('[data-tab-id]')!;
|
|
|
|
// Middle-click on AI tab
|
|
fireEvent.mouseDown(aiTabElement, { button: 1 });
|
|
|
|
// Should call the AI tab close handler, not file tab close handler
|
|
expect(mockOnAiTabClose).toHaveBeenCalledWith('ai-tab-1');
|
|
expect(mockOnFileTabClose).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('Unified active tab styling consistency', () => {
|
|
const mockOnTabSelect = vi.fn();
|
|
const mockOnTabClose = vi.fn();
|
|
const mockOnNewTab = vi.fn();
|
|
const mockOnFileTabSelect = vi.fn();
|
|
const mockOnFileTabClose = vi.fn();
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it('applies same active styling to both AI tabs and file tabs', () => {
|
|
const aiTab = createTab({ id: 'ai-tab-1', name: 'AI Tab' });
|
|
const fileTab: FilePreviewTab = {
|
|
id: 'file-tab-1',
|
|
path: '/test/example.tsx',
|
|
name: 'example',
|
|
extension: '.tsx',
|
|
content: 'const Example = () => {};',
|
|
scrollTop: 0,
|
|
searchQuery: '',
|
|
editMode: false,
|
|
editContent: undefined,
|
|
createdAt: Date.now(),
|
|
lastModified: Date.now(),
|
|
};
|
|
|
|
const unifiedTabs = [
|
|
{ type: 'ai' as const, id: 'ai-tab-1', data: aiTab },
|
|
{ type: 'file' as const, id: 'file-tab-1', data: fileTab },
|
|
];
|
|
|
|
// Test 1: Active AI tab styling
|
|
const { rerender } = render(
|
|
<TabBar
|
|
tabs={[aiTab]}
|
|
activeTabId="ai-tab-1"
|
|
theme={mockTheme}
|
|
onTabSelect={mockOnTabSelect}
|
|
onTabClose={mockOnTabClose}
|
|
onNewTab={mockOnNewTab}
|
|
unifiedTabs={unifiedTabs}
|
|
activeFileTabId={null}
|
|
onFileTabSelect={mockOnFileTabSelect}
|
|
onFileTabClose={mockOnFileTabClose}
|
|
/>
|
|
);
|
|
|
|
const activeAiTab = screen.getByText('AI Tab').closest('[data-tab-id]')!;
|
|
expect(activeAiTab).toHaveStyle({ backgroundColor: mockTheme.colors.bgMain });
|
|
expect(activeAiTab).toHaveStyle({ borderTopLeftRadius: '6px' });
|
|
expect(activeAiTab).toHaveStyle({ borderTopRightRadius: '6px' });
|
|
expect(activeAiTab).toHaveStyle({ marginBottom: '-1px' });
|
|
expect(activeAiTab).toHaveStyle({ zIndex: '1' });
|
|
|
|
// Test 2: Active file tab styling - switch active tab
|
|
rerender(
|
|
<TabBar
|
|
tabs={[aiTab]}
|
|
activeTabId="ai-tab-1"
|
|
theme={mockTheme}
|
|
onTabSelect={mockOnTabSelect}
|
|
onTabClose={mockOnTabClose}
|
|
onNewTab={mockOnNewTab}
|
|
unifiedTabs={unifiedTabs}
|
|
activeFileTabId="file-tab-1"
|
|
onFileTabSelect={mockOnFileTabSelect}
|
|
onFileTabClose={mockOnFileTabClose}
|
|
/>
|
|
);
|
|
|
|
const activeFileTab = screen.getByText('example').closest('[data-tab-id]')!;
|
|
// File tabs should have the same active styling as AI tabs
|
|
expect(activeFileTab).toHaveStyle({ backgroundColor: mockTheme.colors.bgMain });
|
|
expect(activeFileTab).toHaveStyle({ borderTopLeftRadius: '6px' });
|
|
expect(activeFileTab).toHaveStyle({ borderTopRightRadius: '6px' });
|
|
expect(activeFileTab).toHaveStyle({ marginBottom: '-1px' });
|
|
expect(activeFileTab).toHaveStyle({ zIndex: '1' });
|
|
});
|
|
|
|
it('applies same inactive styling to both AI tabs and file tabs', () => {
|
|
const aiTab = createTab({ id: 'ai-tab-1', name: 'AI Tab' });
|
|
const fileTab: FilePreviewTab = {
|
|
id: 'file-tab-1',
|
|
path: '/test/example.tsx',
|
|
name: 'example',
|
|
extension: '.tsx',
|
|
content: 'const Example = () => {};',
|
|
scrollTop: 0,
|
|
searchQuery: '',
|
|
editMode: false,
|
|
editContent: undefined,
|
|
createdAt: Date.now(),
|
|
lastModified: Date.now(),
|
|
};
|
|
|
|
const unifiedTabs = [
|
|
{ type: 'ai' as const, id: 'ai-tab-1', data: aiTab },
|
|
{ type: 'file' as const, id: 'file-tab-1', data: fileTab },
|
|
];
|
|
|
|
// Render with AI tab active (file tab inactive)
|
|
render(
|
|
<TabBar
|
|
tabs={[aiTab]}
|
|
activeTabId="ai-tab-1"
|
|
theme={mockTheme}
|
|
onTabSelect={mockOnTabSelect}
|
|
onTabClose={mockOnTabClose}
|
|
onNewTab={mockOnNewTab}
|
|
unifiedTabs={unifiedTabs}
|
|
activeFileTabId={null}
|
|
onFileTabSelect={mockOnFileTabSelect}
|
|
onFileTabClose={mockOnFileTabClose}
|
|
/>
|
|
);
|
|
|
|
const inactiveFileTab = screen.getByText('example').closest('[data-tab-id]') as HTMLElement;
|
|
// Inactive file tab should NOT have the active background color (bright background)
|
|
// It may be transparent or empty depending on how JSDOM handles it
|
|
const bgColor = inactiveFileTab.style.backgroundColor;
|
|
expect(bgColor === 'transparent' || bgColor === '').toBe(true);
|
|
expect(inactiveFileTab).toHaveStyle({ marginBottom: '0' });
|
|
expect(inactiveFileTab).toHaveStyle({ zIndex: '0' });
|
|
});
|
|
|
|
it('file tab displays extension badge with file extension text', () => {
|
|
const aiTab = createTab({ id: 'ai-tab-1', name: 'AI Tab' });
|
|
const fileTab: FilePreviewTab = {
|
|
id: 'file-tab-1',
|
|
path: '/test/example.tsx',
|
|
name: 'example',
|
|
extension: '.tsx',
|
|
content: 'const Example = () => {};',
|
|
scrollTop: 0,
|
|
searchQuery: '',
|
|
editMode: false,
|
|
editContent: undefined,
|
|
createdAt: Date.now(),
|
|
lastModified: Date.now(),
|
|
};
|
|
|
|
const unifiedTabs = [
|
|
{ type: 'ai' as const, id: 'ai-tab-1', data: aiTab },
|
|
{ type: 'file' as const, id: 'file-tab-1', data: fileTab },
|
|
];
|
|
|
|
render(
|
|
<TabBar
|
|
tabs={[aiTab]}
|
|
activeTabId="ai-tab-1"
|
|
theme={mockTheme}
|
|
onTabSelect={mockOnTabSelect}
|
|
onTabClose={mockOnTabClose}
|
|
onNewTab={mockOnNewTab}
|
|
unifiedTabs={unifiedTabs}
|
|
activeFileTabId="file-tab-1"
|
|
onFileTabSelect={mockOnFileTabSelect}
|
|
onFileTabClose={mockOnFileTabClose}
|
|
/>
|
|
);
|
|
|
|
// File tab should show extension badge (uppercase, without leading dot)
|
|
const extensionBadge = screen.getByText('TSX');
|
|
expect(extensionBadge).toBeInTheDocument();
|
|
// Verify it has the uppercase and small badge styling
|
|
expect(extensionBadge.className).toContain('uppercase');
|
|
});
|
|
});
|
|
|
|
describe('File tab content and SSH support', () => {
|
|
const mockOnTabSelect = vi.fn();
|
|
const mockOnTabClose = vi.fn();
|
|
const mockOnNewTab = vi.fn();
|
|
const mockOnFileTabSelect = vi.fn();
|
|
const mockOnFileTabClose = vi.fn();
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it('file tab stores content field', () => {
|
|
const aiTab = createTab({ id: 'ai-tab-1', name: 'AI Tab' });
|
|
const fileContent = '# Test Content\n\nThis is the file content stored on the tab.';
|
|
const fileTab: FilePreviewTab = {
|
|
id: 'file-tab-1',
|
|
path: '/test/readme.md',
|
|
name: 'readme',
|
|
extension: '.md',
|
|
content: fileContent, // Content is stored on the tab
|
|
scrollTop: 0,
|
|
searchQuery: '',
|
|
editMode: false,
|
|
editContent: undefined,
|
|
createdAt: Date.now(),
|
|
lastModified: Date.now(),
|
|
};
|
|
|
|
const unifiedTabs = [
|
|
{ type: 'ai' as const, id: 'ai-tab-1', data: aiTab },
|
|
{ type: 'file' as const, id: 'file-tab-1', data: fileTab },
|
|
];
|
|
|
|
render(
|
|
<TabBar
|
|
tabs={[aiTab]}
|
|
activeTabId="ai-tab-1"
|
|
theme={mockTheme}
|
|
onTabSelect={mockOnTabSelect}
|
|
onTabClose={mockOnTabClose}
|
|
onNewTab={mockOnNewTab}
|
|
unifiedTabs={unifiedTabs}
|
|
activeFileTabId="file-tab-1"
|
|
onFileTabSelect={mockOnFileTabSelect}
|
|
onFileTabClose={mockOnFileTabClose}
|
|
/>
|
|
);
|
|
|
|
// Verify the file tab renders (content is used by MainPanel, not TabBar)
|
|
expect(screen.getByText('readme')).toBeInTheDocument();
|
|
// Verify the content is stored on the tab data
|
|
expect(fileTab.content).toBe(fileContent);
|
|
});
|
|
|
|
it('file tab supports SSH remote ID', () => {
|
|
const aiTab = createTab({ id: 'ai-tab-1', name: 'AI Tab' });
|
|
const fileTab: FilePreviewTab = {
|
|
id: 'file-tab-1',
|
|
path: '/remote/project/src/main.ts',
|
|
name: 'main',
|
|
extension: '.ts',
|
|
content: 'export const main = () => {}',
|
|
scrollTop: 0,
|
|
searchQuery: '',
|
|
editMode: false,
|
|
editContent: undefined,
|
|
createdAt: Date.now(),
|
|
lastModified: Date.now(),
|
|
sshRemoteId: 'ssh-remote-123', // SSH remote ID for re-fetching
|
|
isLoading: false,
|
|
};
|
|
|
|
const unifiedTabs = [
|
|
{ type: 'ai' as const, id: 'ai-tab-1', data: aiTab },
|
|
{ type: 'file' as const, id: 'file-tab-1', data: fileTab },
|
|
];
|
|
|
|
render(
|
|
<TabBar
|
|
tabs={[aiTab]}
|
|
activeTabId="ai-tab-1"
|
|
theme={mockTheme}
|
|
onTabSelect={mockOnTabSelect}
|
|
onTabClose={mockOnTabClose}
|
|
onNewTab={mockOnNewTab}
|
|
unifiedTabs={unifiedTabs}
|
|
activeFileTabId="file-tab-1"
|
|
onFileTabSelect={mockOnFileTabSelect}
|
|
onFileTabClose={mockOnFileTabClose}
|
|
/>
|
|
);
|
|
|
|
// Verify the file tab renders
|
|
expect(screen.getByText('main')).toBeInTheDocument();
|
|
// Verify SSH remote ID is stored
|
|
expect(fileTab.sshRemoteId).toBe('ssh-remote-123');
|
|
expect(fileTab.isLoading).toBe(false);
|
|
});
|
|
|
|
it('file tab can be in loading state for SSH files', () => {
|
|
const aiTab = createTab({ id: 'ai-tab-1', name: 'AI Tab' });
|
|
const fileTab: FilePreviewTab = {
|
|
id: 'file-tab-1',
|
|
path: '/remote/project/loading.ts',
|
|
name: 'loading',
|
|
extension: '.ts',
|
|
content: '', // Empty while loading
|
|
scrollTop: 0,
|
|
searchQuery: '',
|
|
editMode: false,
|
|
editContent: undefined,
|
|
createdAt: Date.now(),
|
|
lastModified: 0, // Not yet loaded
|
|
sshRemoteId: 'ssh-remote-456',
|
|
isLoading: true, // Currently loading content
|
|
};
|
|
|
|
const unifiedTabs = [
|
|
{ type: 'ai' as const, id: 'ai-tab-1', data: aiTab },
|
|
{ type: 'file' as const, id: 'file-tab-1', data: fileTab },
|
|
];
|
|
|
|
render(
|
|
<TabBar
|
|
tabs={[aiTab]}
|
|
activeTabId="ai-tab-1"
|
|
theme={mockTheme}
|
|
onTabSelect={mockOnTabSelect}
|
|
onTabClose={mockOnTabClose}
|
|
onNewTab={mockOnNewTab}
|
|
unifiedTabs={unifiedTabs}
|
|
activeFileTabId="file-tab-1"
|
|
onFileTabSelect={mockOnFileTabSelect}
|
|
onFileTabClose={mockOnFileTabClose}
|
|
/>
|
|
);
|
|
|
|
// Tab still renders while loading
|
|
expect(screen.getByText('loading')).toBeInTheDocument();
|
|
// Verify loading state
|
|
expect(fileTab.isLoading).toBe(true);
|
|
expect(fileTab.content).toBe('');
|
|
});
|
|
|
|
it('file tab editContent takes precedence over content when set', () => {
|
|
const aiTab = createTab({ id: 'ai-tab-1', name: 'AI Tab' });
|
|
const originalContent = 'Original file content';
|
|
const editedContent = 'Edited content not yet saved';
|
|
const fileTab: FilePreviewTab = {
|
|
id: 'file-tab-1',
|
|
path: '/test/edited.md',
|
|
name: 'edited',
|
|
extension: '.md',
|
|
content: originalContent,
|
|
scrollTop: 100,
|
|
searchQuery: 'search',
|
|
editMode: true,
|
|
editContent: editedContent, // Has unsaved edits
|
|
createdAt: Date.now(),
|
|
lastModified: Date.now(),
|
|
};
|
|
|
|
const unifiedTabs = [
|
|
{ type: 'ai' as const, id: 'ai-tab-1', data: aiTab },
|
|
{ type: 'file' as const, id: 'file-tab-1', data: fileTab },
|
|
];
|
|
|
|
render(
|
|
<TabBar
|
|
tabs={[aiTab]}
|
|
activeTabId="ai-tab-1"
|
|
theme={mockTheme}
|
|
onTabSelect={mockOnTabSelect}
|
|
onTabClose={mockOnTabClose}
|
|
onNewTab={mockOnNewTab}
|
|
unifiedTabs={unifiedTabs}
|
|
activeFileTabId="file-tab-1"
|
|
onFileTabSelect={mockOnFileTabSelect}
|
|
onFileTabClose={mockOnFileTabClose}
|
|
/>
|
|
);
|
|
|
|
// Tab renders
|
|
expect(screen.getByText('edited')).toBeInTheDocument();
|
|
// Verify both content fields exist (MainPanel uses editContent ?? content)
|
|
expect(fileTab.content).toBe(originalContent);
|
|
expect(fileTab.editContent).toBe(editedContent);
|
|
expect(fileTab.editMode).toBe(true);
|
|
});
|
|
});
|
|
|
|
// Extension badge styling tests for visual polish across themes
|
|
describe('Extension badge styling across themes', () => {
|
|
const mockOnTabSelect = vi.fn();
|
|
const mockOnTabClose = vi.fn();
|
|
const mockOnNewTab = vi.fn();
|
|
const mockOnFileTabSelect = vi.fn();
|
|
const mockOnFileTabClose = vi.fn();
|
|
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
vi.clearAllMocks();
|
|
Element.prototype.scrollTo = vi.fn();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
// Light theme for testing contrast
|
|
const lightTheme: Theme = {
|
|
id: 'github-light',
|
|
name: 'GitHub Light',
|
|
mode: 'light',
|
|
colors: {
|
|
bgMain: '#ffffff',
|
|
bgSidebar: '#f6f8fa',
|
|
bgActivity: '#eff2f5',
|
|
textMain: '#24292f',
|
|
textDim: '#57606a',
|
|
accent: '#0969da',
|
|
border: '#d0d7de',
|
|
error: '#cf222e',
|
|
success: '#1a7f37',
|
|
warning: '#9a6700',
|
|
},
|
|
};
|
|
|
|
// Dark theme for comparison
|
|
const darkTheme: Theme = {
|
|
id: 'dracula',
|
|
name: 'Dracula',
|
|
mode: 'dark',
|
|
colors: {
|
|
bgMain: '#282a36',
|
|
bgSidebar: '#21222c',
|
|
bgActivity: '#343746',
|
|
textMain: '#f8f8f2',
|
|
textDim: '#6272a4',
|
|
accent: '#bd93f9',
|
|
border: '#44475a',
|
|
error: '#ff5555',
|
|
success: '#50fa7b',
|
|
warning: '#ffb86c',
|
|
},
|
|
};
|
|
|
|
const createFileTab = (extension: string): FilePreviewTab => ({
|
|
id: `file-tab-${extension}`,
|
|
path: `/test/file${extension}`,
|
|
name: 'file',
|
|
extension: extension,
|
|
content: 'test content',
|
|
scrollTop: 0,
|
|
searchQuery: '',
|
|
editMode: false,
|
|
editContent: undefined,
|
|
createdAt: Date.now(),
|
|
lastModified: Date.now(),
|
|
});
|
|
|
|
it('renders extension badges for TypeScript files with appropriate styling', () => {
|
|
const aiTab = createTab({ id: 'ai-tab-1', name: 'AI Tab' });
|
|
const fileTab = createFileTab('.ts');
|
|
|
|
const unifiedTabs = [
|
|
{ type: 'ai' as const, id: 'ai-tab-1', data: aiTab },
|
|
{ type: 'file' as const, id: fileTab.id, data: fileTab },
|
|
];
|
|
|
|
render(
|
|
<TabBar
|
|
tabs={[aiTab]}
|
|
activeTabId="ai-tab-1"
|
|
theme={darkTheme}
|
|
onTabSelect={mockOnTabSelect}
|
|
onTabClose={mockOnTabClose}
|
|
onNewTab={mockOnNewTab}
|
|
unifiedTabs={unifiedTabs}
|
|
activeFileTabId={null}
|
|
onFileTabSelect={mockOnFileTabSelect}
|
|
onFileTabClose={mockOnFileTabClose}
|
|
/>
|
|
);
|
|
|
|
// Extension badge should be rendered
|
|
const badge = screen.getByText('TS');
|
|
expect(badge).toBeInTheDocument();
|
|
// Badge should have blue-ish background for TypeScript
|
|
expect(badge).toHaveStyle({ backgroundColor: 'rgba(59, 130, 246, 0.3)' });
|
|
});
|
|
|
|
it('renders extension badges for TypeScript files with light theme appropriate styling', () => {
|
|
const aiTab = createTab({ id: 'ai-tab-1', name: 'AI Tab' });
|
|
const fileTab = createFileTab('.tsx');
|
|
|
|
const unifiedTabs = [
|
|
{ type: 'ai' as const, id: 'ai-tab-1', data: aiTab },
|
|
{ type: 'file' as const, id: fileTab.id, data: fileTab },
|
|
];
|
|
|
|
render(
|
|
<TabBar
|
|
tabs={[aiTab]}
|
|
activeTabId="ai-tab-1"
|
|
theme={lightTheme}
|
|
onTabSelect={mockOnTabSelect}
|
|
onTabClose={mockOnTabClose}
|
|
onNewTab={mockOnNewTab}
|
|
unifiedTabs={unifiedTabs}
|
|
activeFileTabId={null}
|
|
onFileTabSelect={mockOnFileTabSelect}
|
|
onFileTabClose={mockOnFileTabClose}
|
|
/>
|
|
);
|
|
|
|
// Extension badge should be rendered with light theme colors
|
|
const badge = screen.getByText('TSX');
|
|
expect(badge).toBeInTheDocument();
|
|
// Badge should have darker blue for better contrast on light backgrounds
|
|
expect(badge).toHaveStyle({ backgroundColor: 'rgba(37, 99, 235, 0.15)' });
|
|
});
|
|
|
|
it('renders extension badges for Markdown files with dark theme styling', () => {
|
|
const aiTab = createTab({ id: 'ai-tab-1', name: 'AI Tab' });
|
|
const fileTab = createFileTab('.md');
|
|
|
|
const unifiedTabs = [
|
|
{ type: 'ai' as const, id: 'ai-tab-1', data: aiTab },
|
|
{ type: 'file' as const, id: fileTab.id, data: fileTab },
|
|
];
|
|
|
|
render(
|
|
<TabBar
|
|
tabs={[aiTab]}
|
|
activeTabId="ai-tab-1"
|
|
theme={darkTheme}
|
|
onTabSelect={mockOnTabSelect}
|
|
onTabClose={mockOnTabClose}
|
|
onNewTab={mockOnNewTab}
|
|
unifiedTabs={unifiedTabs}
|
|
activeFileTabId={null}
|
|
onFileTabSelect={mockOnFileTabSelect}
|
|
onFileTabClose={mockOnFileTabClose}
|
|
/>
|
|
);
|
|
|
|
const badge = screen.getByText('MD');
|
|
expect(badge).toBeInTheDocument();
|
|
// Green tones for Markdown/Docs
|
|
expect(badge).toHaveStyle({ backgroundColor: 'rgba(34, 197, 94, 0.3)' });
|
|
});
|
|
|
|
it('renders extension badges for JSON files with dark theme styling', () => {
|
|
const aiTab = createTab({ id: 'ai-tab-1', name: 'AI Tab' });
|
|
const fileTab = createFileTab('.json');
|
|
|
|
const unifiedTabs = [
|
|
{ type: 'ai' as const, id: 'ai-tab-1', data: aiTab },
|
|
{ type: 'file' as const, id: fileTab.id, data: fileTab },
|
|
];
|
|
|
|
render(
|
|
<TabBar
|
|
tabs={[aiTab]}
|
|
activeTabId="ai-tab-1"
|
|
theme={darkTheme}
|
|
onTabSelect={mockOnTabSelect}
|
|
onTabClose={mockOnTabClose}
|
|
onNewTab={mockOnNewTab}
|
|
unifiedTabs={unifiedTabs}
|
|
activeFileTabId={null}
|
|
onFileTabSelect={mockOnFileTabSelect}
|
|
onFileTabClose={mockOnFileTabClose}
|
|
/>
|
|
);
|
|
|
|
const badge = screen.getByText('JSON');
|
|
expect(badge).toBeInTheDocument();
|
|
// Yellow tones for JSON/Config
|
|
expect(badge).toHaveStyle({ backgroundColor: 'rgba(234, 179, 8, 0.3)' });
|
|
});
|
|
|
|
it('renders extension badges for CSS files with dark theme styling', () => {
|
|
const aiTab = createTab({ id: 'ai-tab-1', name: 'AI Tab' });
|
|
const fileTab = createFileTab('.css');
|
|
|
|
const unifiedTabs = [
|
|
{ type: 'ai' as const, id: 'ai-tab-1', data: aiTab },
|
|
{ type: 'file' as const, id: fileTab.id, data: fileTab },
|
|
];
|
|
|
|
render(
|
|
<TabBar
|
|
tabs={[aiTab]}
|
|
activeTabId="ai-tab-1"
|
|
theme={darkTheme}
|
|
onTabSelect={mockOnTabSelect}
|
|
onTabClose={mockOnTabClose}
|
|
onNewTab={mockOnNewTab}
|
|
unifiedTabs={unifiedTabs}
|
|
activeFileTabId={null}
|
|
onFileTabSelect={mockOnFileTabSelect}
|
|
onFileTabClose={mockOnFileTabClose}
|
|
/>
|
|
);
|
|
|
|
const badge = screen.getByText('CSS');
|
|
expect(badge).toBeInTheDocument();
|
|
// Purple tones for CSS/Styles
|
|
expect(badge).toHaveStyle({ backgroundColor: 'rgba(168, 85, 247, 0.3)' });
|
|
});
|
|
|
|
it('renders extension badges for HTML files with dark theme styling', () => {
|
|
const aiTab = createTab({ id: 'ai-tab-1', name: 'AI Tab' });
|
|
const fileTab = createFileTab('.html');
|
|
|
|
const unifiedTabs = [
|
|
{ type: 'ai' as const, id: 'ai-tab-1', data: aiTab },
|
|
{ type: 'file' as const, id: fileTab.id, data: fileTab },
|
|
];
|
|
|
|
render(
|
|
<TabBar
|
|
tabs={[aiTab]}
|
|
activeTabId="ai-tab-1"
|
|
theme={darkTheme}
|
|
onTabSelect={mockOnTabSelect}
|
|
onTabClose={mockOnTabClose}
|
|
onNewTab={mockOnNewTab}
|
|
unifiedTabs={unifiedTabs}
|
|
activeFileTabId={null}
|
|
onFileTabSelect={mockOnFileTabSelect}
|
|
onFileTabClose={mockOnFileTabClose}
|
|
/>
|
|
);
|
|
|
|
const badge = screen.getByText('HTML');
|
|
expect(badge).toBeInTheDocument();
|
|
// Orange tones for HTML/Templates
|
|
expect(badge).toHaveStyle({ backgroundColor: 'rgba(249, 115, 22, 0.3)' });
|
|
});
|
|
|
|
it('renders extension badges for Python files with dark theme styling', () => {
|
|
const aiTab = createTab({ id: 'ai-tab-1', name: 'AI Tab' });
|
|
const fileTab = createFileTab('.py');
|
|
|
|
const unifiedTabs = [
|
|
{ type: 'ai' as const, id: 'ai-tab-1', data: aiTab },
|
|
{ type: 'file' as const, id: fileTab.id, data: fileTab },
|
|
];
|
|
|
|
render(
|
|
<TabBar
|
|
tabs={[aiTab]}
|
|
activeTabId="ai-tab-1"
|
|
theme={darkTheme}
|
|
onTabSelect={mockOnTabSelect}
|
|
onTabClose={mockOnTabClose}
|
|
onNewTab={mockOnNewTab}
|
|
unifiedTabs={unifiedTabs}
|
|
activeFileTabId={null}
|
|
onFileTabSelect={mockOnFileTabSelect}
|
|
onFileTabClose={mockOnFileTabClose}
|
|
/>
|
|
);
|
|
|
|
const badge = screen.getByText('PY');
|
|
expect(badge).toBeInTheDocument();
|
|
// Teal/cyan tones for Python
|
|
expect(badge).toHaveStyle({ backgroundColor: 'rgba(20, 184, 166, 0.3)' });
|
|
});
|
|
|
|
it('renders extension badges for Rust files with dark theme styling', () => {
|
|
const aiTab = createTab({ id: 'ai-tab-1', name: 'AI Tab' });
|
|
const fileTab = createFileTab('.rs');
|
|
|
|
const unifiedTabs = [
|
|
{ type: 'ai' as const, id: 'ai-tab-1', data: aiTab },
|
|
{ type: 'file' as const, id: fileTab.id, data: fileTab },
|
|
];
|
|
|
|
render(
|
|
<TabBar
|
|
tabs={[aiTab]}
|
|
activeTabId="ai-tab-1"
|
|
theme={darkTheme}
|
|
onTabSelect={mockOnTabSelect}
|
|
onTabClose={mockOnTabClose}
|
|
onNewTab={mockOnNewTab}
|
|
unifiedTabs={unifiedTabs}
|
|
activeFileTabId={null}
|
|
onFileTabSelect={mockOnFileTabSelect}
|
|
onFileTabClose={mockOnFileTabClose}
|
|
/>
|
|
);
|
|
|
|
const badge = screen.getByText('RS');
|
|
expect(badge).toBeInTheDocument();
|
|
// Rust/red-orange tones for Rust
|
|
expect(badge).toHaveStyle({ backgroundColor: 'rgba(239, 68, 68, 0.3)' });
|
|
});
|
|
|
|
it('renders extension badges for unknown files using theme border color', () => {
|
|
const aiTab = createTab({ id: 'ai-tab-1', name: 'AI Tab' });
|
|
const fileTab = createFileTab('.xyz');
|
|
|
|
const unifiedTabs = [
|
|
{ type: 'ai' as const, id: 'ai-tab-1', data: aiTab },
|
|
{ type: 'file' as const, id: fileTab.id, data: fileTab },
|
|
];
|
|
|
|
render(
|
|
<TabBar
|
|
tabs={[aiTab]}
|
|
activeTabId="ai-tab-1"
|
|
theme={darkTheme}
|
|
onTabSelect={mockOnTabSelect}
|
|
onTabClose={mockOnTabClose}
|
|
onNewTab={mockOnNewTab}
|
|
unifiedTabs={unifiedTabs}
|
|
activeFileTabId={null}
|
|
onFileTabSelect={mockOnFileTabSelect}
|
|
onFileTabClose={mockOnFileTabClose}
|
|
/>
|
|
);
|
|
|
|
const badge = screen.getByText('XYZ');
|
|
expect(badge).toBeInTheDocument();
|
|
// Uses theme border color for unknown extensions
|
|
expect(badge).toHaveStyle({ backgroundColor: darkTheme.colors.border });
|
|
});
|
|
|
|
it('renders consistent tab name truncation for file tabs (max-w-[120px])', () => {
|
|
const aiTab = createTab({ id: 'ai-tab-1', name: 'AI Tab' });
|
|
const fileTab: FilePreviewTab = {
|
|
id: 'file-tab-1',
|
|
path: '/test/very-long-filename-that-should-be-truncated.ts',
|
|
name: 'very-long-filename-that-should-be-truncated',
|
|
extension: '.ts',
|
|
content: 'test',
|
|
scrollTop: 0,
|
|
searchQuery: '',
|
|
editMode: false,
|
|
editContent: undefined,
|
|
createdAt: Date.now(),
|
|
lastModified: Date.now(),
|
|
};
|
|
|
|
const unifiedTabs = [
|
|
{ type: 'ai' as const, id: 'ai-tab-1', data: aiTab },
|
|
{ type: 'file' as const, id: fileTab.id, data: fileTab },
|
|
];
|
|
|
|
render(
|
|
<TabBar
|
|
tabs={[aiTab]}
|
|
activeTabId="ai-tab-1" // AI tab active, file tab inactive
|
|
theme={darkTheme}
|
|
onTabSelect={mockOnTabSelect}
|
|
onTabClose={mockOnTabClose}
|
|
onNewTab={mockOnNewTab}
|
|
unifiedTabs={unifiedTabs}
|
|
activeFileTabId={null}
|
|
onFileTabSelect={mockOnFileTabSelect}
|
|
onFileTabClose={mockOnFileTabClose}
|
|
/>
|
|
);
|
|
|
|
// File tab name span should have truncation class
|
|
const fileNameSpan = screen.getByText('very-long-filename-that-should-be-truncated');
|
|
expect(fileNameSpan).toHaveClass('truncate');
|
|
expect(fileNameSpan).toHaveClass('max-w-[120px]');
|
|
});
|
|
});
|
|
|
|
describe('File tab extension badge colorblind mode', () => {
|
|
const mockOnTabSelect = vi.fn();
|
|
const mockOnTabClose = vi.fn();
|
|
const mockOnNewTab = vi.fn();
|
|
const mockOnFileTabSelect = vi.fn();
|
|
const mockOnFileTabClose = vi.fn();
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
// Light theme for testing contrast
|
|
const lightTheme: Theme = {
|
|
id: 'github-light',
|
|
name: 'GitHub Light',
|
|
mode: 'light',
|
|
colors: {
|
|
bgMain: '#ffffff',
|
|
bgSidebar: '#f6f8fa',
|
|
bgActivity: '#eff2f5',
|
|
textMain: '#24292f',
|
|
textDim: '#57606a',
|
|
accent: '#0969da',
|
|
border: '#d0d7de',
|
|
error: '#cf222e',
|
|
success: '#1a7f37',
|
|
warning: '#9a6700',
|
|
},
|
|
};
|
|
|
|
// Dark theme for comparison
|
|
const darkTheme: Theme = {
|
|
id: 'dracula',
|
|
name: 'Dracula',
|
|
mode: 'dark',
|
|
colors: {
|
|
bgMain: '#282a36',
|
|
bgSidebar: '#21222c',
|
|
bgActivity: '#343746',
|
|
textMain: '#f8f8f2',
|
|
textDim: '#6272a4',
|
|
accent: '#bd93f9',
|
|
border: '#44475a',
|
|
error: '#ff5555',
|
|
success: '#50fa7b',
|
|
warning: '#ffb86c',
|
|
},
|
|
};
|
|
|
|
const createTab = (overrides: Partial<AITab> = {}): AITab => ({
|
|
id: 'test-tab',
|
|
name: '',
|
|
agentSessionId: 'abc12345-def6-7890',
|
|
logs: [],
|
|
...overrides,
|
|
});
|
|
|
|
const createFileTab = (extension: string): FilePreviewTab => ({
|
|
id: `file-tab-${extension}`,
|
|
path: `/test/example${extension}`,
|
|
name: 'example',
|
|
extension: extension,
|
|
content: 'test content',
|
|
scrollTop: 0,
|
|
searchQuery: '',
|
|
editMode: false,
|
|
editContent: undefined,
|
|
createdAt: Date.now(),
|
|
lastModified: Date.now(),
|
|
});
|
|
|
|
it('renders colorblind-safe colors for TypeScript files in dark mode', () => {
|
|
const aiTab = createTab({ id: 'ai-tab-1', name: 'AI Tab' });
|
|
const fileTab = createFileTab('.ts');
|
|
|
|
const unifiedTabs = [
|
|
{ type: 'ai' as const, id: 'ai-tab-1', data: aiTab },
|
|
{ type: 'file' as const, id: fileTab.id, data: fileTab },
|
|
];
|
|
|
|
render(
|
|
<TabBar
|
|
tabs={[aiTab]}
|
|
activeTabId="ai-tab-1"
|
|
theme={darkTheme}
|
|
onTabSelect={mockOnTabSelect}
|
|
onTabClose={mockOnTabClose}
|
|
onNewTab={mockOnNewTab}
|
|
unifiedTabs={unifiedTabs}
|
|
activeFileTabId={null}
|
|
onFileTabSelect={mockOnFileTabSelect}
|
|
onFileTabClose={mockOnFileTabClose}
|
|
colorBlindMode={true}
|
|
/>
|
|
);
|
|
|
|
const badge = screen.getByText('TS');
|
|
expect(badge).toBeInTheDocument();
|
|
// Strong Blue (#0077BB) from Wong's colorblind-safe palette
|
|
expect(badge).toHaveStyle({ backgroundColor: 'rgba(0, 119, 187, 0.35)' });
|
|
});
|
|
|
|
it('renders colorblind-safe colors for TypeScript files in light mode', () => {
|
|
const aiTab = createTab({ id: 'ai-tab-1', name: 'AI Tab' });
|
|
const fileTab = createFileTab('.tsx');
|
|
|
|
const unifiedTabs = [
|
|
{ type: 'ai' as const, id: 'ai-tab-1', data: aiTab },
|
|
{ type: 'file' as const, id: fileTab.id, data: fileTab },
|
|
];
|
|
|
|
render(
|
|
<TabBar
|
|
tabs={[aiTab]}
|
|
activeTabId="ai-tab-1"
|
|
theme={lightTheme}
|
|
onTabSelect={mockOnTabSelect}
|
|
onTabClose={mockOnTabClose}
|
|
onNewTab={mockOnNewTab}
|
|
unifiedTabs={unifiedTabs}
|
|
activeFileTabId={null}
|
|
onFileTabSelect={mockOnFileTabSelect}
|
|
onFileTabClose={mockOnFileTabClose}
|
|
colorBlindMode={true}
|
|
/>
|
|
);
|
|
|
|
const badge = screen.getByText('TSX');
|
|
expect(badge).toBeInTheDocument();
|
|
// Strong Blue (#0077BB) lighter for light theme
|
|
expect(badge).toHaveStyle({ backgroundColor: 'rgba(0, 119, 187, 0.18)' });
|
|
});
|
|
|
|
it('renders colorblind-safe colors for Markdown files (teal)', () => {
|
|
const aiTab = createTab({ id: 'ai-tab-1', name: 'AI Tab' });
|
|
const fileTab = createFileTab('.md');
|
|
|
|
const unifiedTabs = [
|
|
{ type: 'ai' as const, id: 'ai-tab-1', data: aiTab },
|
|
{ type: 'file' as const, id: fileTab.id, data: fileTab },
|
|
];
|
|
|
|
render(
|
|
<TabBar
|
|
tabs={[aiTab]}
|
|
activeTabId="ai-tab-1"
|
|
theme={darkTheme}
|
|
onTabSelect={mockOnTabSelect}
|
|
onTabClose={mockOnTabClose}
|
|
onNewTab={mockOnNewTab}
|
|
unifiedTabs={unifiedTabs}
|
|
activeFileTabId={null}
|
|
onFileTabSelect={mockOnFileTabSelect}
|
|
onFileTabClose={mockOnFileTabClose}
|
|
colorBlindMode={true}
|
|
/>
|
|
);
|
|
|
|
const badge = screen.getByText('MD');
|
|
expect(badge).toBeInTheDocument();
|
|
// Teal (#009988) from Wong's colorblind-safe palette
|
|
expect(badge).toHaveStyle({ backgroundColor: 'rgba(0, 153, 136, 0.35)' });
|
|
});
|
|
|
|
it('renders colorblind-safe colors for JSON/Config files (orange)', () => {
|
|
const aiTab = createTab({ id: 'ai-tab-1', name: 'AI Tab' });
|
|
const fileTab = createFileTab('.json');
|
|
|
|
const unifiedTabs = [
|
|
{ type: 'ai' as const, id: 'ai-tab-1', data: aiTab },
|
|
{ type: 'file' as const, id: fileTab.id, data: fileTab },
|
|
];
|
|
|
|
render(
|
|
<TabBar
|
|
tabs={[aiTab]}
|
|
activeTabId="ai-tab-1"
|
|
theme={darkTheme}
|
|
onTabSelect={mockOnTabSelect}
|
|
onTabClose={mockOnTabClose}
|
|
onNewTab={mockOnNewTab}
|
|
unifiedTabs={unifiedTabs}
|
|
activeFileTabId={null}
|
|
onFileTabSelect={mockOnFileTabSelect}
|
|
onFileTabClose={mockOnFileTabClose}
|
|
colorBlindMode={true}
|
|
/>
|
|
);
|
|
|
|
const badge = screen.getByText('JSON');
|
|
expect(badge).toBeInTheDocument();
|
|
// Orange (#EE7733) from Wong's colorblind-safe palette
|
|
expect(badge).toHaveStyle({ backgroundColor: 'rgba(238, 119, 51, 0.35)' });
|
|
});
|
|
|
|
it('renders colorblind-safe colors for CSS files (purple)', () => {
|
|
const aiTab = createTab({ id: 'ai-tab-1', name: 'AI Tab' });
|
|
const fileTab = createFileTab('.css');
|
|
|
|
const unifiedTabs = [
|
|
{ type: 'ai' as const, id: 'ai-tab-1', data: aiTab },
|
|
{ type: 'file' as const, id: fileTab.id, data: fileTab },
|
|
];
|
|
|
|
render(
|
|
<TabBar
|
|
tabs={[aiTab]}
|
|
activeTabId="ai-tab-1"
|
|
theme={darkTheme}
|
|
onTabSelect={mockOnTabSelect}
|
|
onTabClose={mockOnTabClose}
|
|
onNewTab={mockOnNewTab}
|
|
unifiedTabs={unifiedTabs}
|
|
activeFileTabId={null}
|
|
onFileTabSelect={mockOnFileTabSelect}
|
|
onFileTabClose={mockOnFileTabClose}
|
|
colorBlindMode={true}
|
|
/>
|
|
);
|
|
|
|
const badge = screen.getByText('CSS');
|
|
expect(badge).toBeInTheDocument();
|
|
// Purple (#AA4499) from Wong's colorblind-safe palette
|
|
expect(badge).toHaveStyle({ backgroundColor: 'rgba(170, 68, 153, 0.35)' });
|
|
});
|
|
|
|
it('renders colorblind-safe colors for HTML files (vermillion)', () => {
|
|
const aiTab = createTab({ id: 'ai-tab-1', name: 'AI Tab' });
|
|
const fileTab = createFileTab('.html');
|
|
|
|
const unifiedTabs = [
|
|
{ type: 'ai' as const, id: 'ai-tab-1', data: aiTab },
|
|
{ type: 'file' as const, id: fileTab.id, data: fileTab },
|
|
];
|
|
|
|
render(
|
|
<TabBar
|
|
tabs={[aiTab]}
|
|
activeTabId="ai-tab-1"
|
|
theme={darkTheme}
|
|
onTabSelect={mockOnTabSelect}
|
|
onTabClose={mockOnTabClose}
|
|
onNewTab={mockOnNewTab}
|
|
unifiedTabs={unifiedTabs}
|
|
activeFileTabId={null}
|
|
onFileTabSelect={mockOnFileTabSelect}
|
|
onFileTabClose={mockOnFileTabClose}
|
|
colorBlindMode={true}
|
|
/>
|
|
);
|
|
|
|
const badge = screen.getByText('HTML');
|
|
expect(badge).toBeInTheDocument();
|
|
// Vermillion (#CC3311) from Wong's colorblind-safe palette
|
|
expect(badge).toHaveStyle({ backgroundColor: 'rgba(204, 51, 17, 0.35)' });
|
|
});
|
|
|
|
it('renders colorblind-safe colors for Python files (cyan)', () => {
|
|
const aiTab = createTab({ id: 'ai-tab-1', name: 'AI Tab' });
|
|
const fileTab = createFileTab('.py');
|
|
|
|
const unifiedTabs = [
|
|
{ type: 'ai' as const, id: 'ai-tab-1', data: aiTab },
|
|
{ type: 'file' as const, id: fileTab.id, data: fileTab },
|
|
];
|
|
|
|
render(
|
|
<TabBar
|
|
tabs={[aiTab]}
|
|
activeTabId="ai-tab-1"
|
|
theme={darkTheme}
|
|
onTabSelect={mockOnTabSelect}
|
|
onTabClose={mockOnTabClose}
|
|
onNewTab={mockOnNewTab}
|
|
unifiedTabs={unifiedTabs}
|
|
activeFileTabId={null}
|
|
onFileTabSelect={mockOnFileTabSelect}
|
|
onFileTabClose={mockOnFileTabClose}
|
|
colorBlindMode={true}
|
|
/>
|
|
);
|
|
|
|
const badge = screen.getByText('PY');
|
|
expect(badge).toBeInTheDocument();
|
|
// Cyan (#33BBEE) from Wong's colorblind-safe palette
|
|
expect(badge).toHaveStyle({ backgroundColor: 'rgba(51, 187, 238, 0.35)' });
|
|
});
|
|
|
|
it('renders colorblind-safe colors for Rust files (magenta)', () => {
|
|
const aiTab = createTab({ id: 'ai-tab-1', name: 'AI Tab' });
|
|
const fileTab = createFileTab('.rs');
|
|
|
|
const unifiedTabs = [
|
|
{ type: 'ai' as const, id: 'ai-tab-1', data: aiTab },
|
|
{ type: 'file' as const, id: fileTab.id, data: fileTab },
|
|
];
|
|
|
|
render(
|
|
<TabBar
|
|
tabs={[aiTab]}
|
|
activeTabId="ai-tab-1"
|
|
theme={darkTheme}
|
|
onTabSelect={mockOnTabSelect}
|
|
onTabClose={mockOnTabClose}
|
|
onNewTab={mockOnNewTab}
|
|
unifiedTabs={unifiedTabs}
|
|
activeFileTabId={null}
|
|
onFileTabSelect={mockOnFileTabSelect}
|
|
onFileTabClose={mockOnFileTabClose}
|
|
colorBlindMode={true}
|
|
/>
|
|
);
|
|
|
|
const badge = screen.getByText('RS');
|
|
expect(badge).toBeInTheDocument();
|
|
// Magenta (#EE3377) from Wong's colorblind-safe palette
|
|
expect(badge).toHaveStyle({ backgroundColor: 'rgba(238, 51, 119, 0.35)' });
|
|
});
|
|
|
|
it('renders colorblind-safe colors for Go files (blue-green)', () => {
|
|
const aiTab = createTab({ id: 'ai-tab-1', name: 'AI Tab' });
|
|
const fileTab = createFileTab('.go');
|
|
|
|
const unifiedTabs = [
|
|
{ type: 'ai' as const, id: 'ai-tab-1', data: aiTab },
|
|
{ type: 'file' as const, id: fileTab.id, data: fileTab },
|
|
];
|
|
|
|
render(
|
|
<TabBar
|
|
tabs={[aiTab]}
|
|
activeTabId="ai-tab-1"
|
|
theme={darkTheme}
|
|
onTabSelect={mockOnTabSelect}
|
|
onTabClose={mockOnTabClose}
|
|
onNewTab={mockOnNewTab}
|
|
unifiedTabs={unifiedTabs}
|
|
activeFileTabId={null}
|
|
onFileTabSelect={mockOnFileTabSelect}
|
|
onFileTabClose={mockOnFileTabClose}
|
|
colorBlindMode={true}
|
|
/>
|
|
);
|
|
|
|
const badge = screen.getByText('GO');
|
|
expect(badge).toBeInTheDocument();
|
|
// Blue-Green (#44AA99) from Wong's colorblind-safe palette
|
|
expect(badge).toHaveStyle({ backgroundColor: 'rgba(68, 170, 153, 0.35)' });
|
|
});
|
|
|
|
it('renders colorblind-safe colors for Shell scripts (gray)', () => {
|
|
const aiTab = createTab({ id: 'ai-tab-1', name: 'AI Tab' });
|
|
const fileTab = createFileTab('.sh');
|
|
|
|
const unifiedTabs = [
|
|
{ type: 'ai' as const, id: 'ai-tab-1', data: aiTab },
|
|
{ type: 'file' as const, id: fileTab.id, data: fileTab },
|
|
];
|
|
|
|
render(
|
|
<TabBar
|
|
tabs={[aiTab]}
|
|
activeTabId="ai-tab-1"
|
|
theme={darkTheme}
|
|
onTabSelect={mockOnTabSelect}
|
|
onTabClose={mockOnTabClose}
|
|
onNewTab={mockOnNewTab}
|
|
unifiedTabs={unifiedTabs}
|
|
activeFileTabId={null}
|
|
onFileTabSelect={mockOnFileTabSelect}
|
|
onFileTabClose={mockOnFileTabClose}
|
|
colorBlindMode={true}
|
|
/>
|
|
);
|
|
|
|
const badge = screen.getByText('SH');
|
|
expect(badge).toBeInTheDocument();
|
|
// Gray for shell scripts (distinguishable by luminance)
|
|
expect(badge).toHaveStyle({ backgroundColor: 'rgba(150, 150, 150, 0.35)' });
|
|
});
|
|
|
|
it('falls back to theme colors for unknown extensions in colorblind mode', () => {
|
|
const aiTab = createTab({ id: 'ai-tab-1', name: 'AI Tab' });
|
|
const fileTab = createFileTab('.xyz');
|
|
|
|
const unifiedTabs = [
|
|
{ type: 'ai' as const, id: 'ai-tab-1', data: aiTab },
|
|
{ type: 'file' as const, id: fileTab.id, data: fileTab },
|
|
];
|
|
|
|
render(
|
|
<TabBar
|
|
tabs={[aiTab]}
|
|
activeTabId="ai-tab-1"
|
|
theme={darkTheme}
|
|
onTabSelect={mockOnTabSelect}
|
|
onTabClose={mockOnTabClose}
|
|
onNewTab={mockOnNewTab}
|
|
unifiedTabs={unifiedTabs}
|
|
activeFileTabId={null}
|
|
onFileTabSelect={mockOnFileTabSelect}
|
|
onFileTabClose={mockOnFileTabClose}
|
|
colorBlindMode={true}
|
|
/>
|
|
);
|
|
|
|
const badge = screen.getByText('XYZ');
|
|
expect(badge).toBeInTheDocument();
|
|
// Falls back to theme border color for unknown extensions
|
|
expect(badge).toHaveStyle({ backgroundColor: darkTheme.colors.border });
|
|
});
|
|
});
|
|
|
|
describe('Performance: Many file tabs (10+)', () => {
|
|
const mockOnTabSelect = vi.fn();
|
|
const mockOnTabClose = vi.fn();
|
|
const mockOnNewTab = vi.fn();
|
|
const mockOnFileTabSelect = vi.fn();
|
|
const mockOnFileTabClose = vi.fn();
|
|
const mockOnUnifiedTabReorder = vi.fn();
|
|
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
vi.clearAllMocks();
|
|
Element.prototype.scrollTo = vi.fn();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
// Helper to create many file tabs
|
|
const createManyFileTabs = (count: number): FilePreviewTab[] =>
|
|
Array.from({ length: count }, (_, i) => ({
|
|
id: `file-tab-${i}`,
|
|
path: `/path/to/files/file-${i}.ts`,
|
|
name: `file-${i}`,
|
|
extension: '.ts',
|
|
content: `// Content for file ${i}\nconst x${i} = ${i};`,
|
|
scrollTop: 0,
|
|
searchQuery: '',
|
|
editMode: false,
|
|
editContent: undefined,
|
|
createdAt: Date.now() + i,
|
|
lastModified: Date.now() + i,
|
|
}));
|
|
|
|
// Helper to create unified tabs from file tabs
|
|
const createUnifiedTabsFromFiles = (
|
|
fileTabs: FilePreviewTab[],
|
|
aiTab: AITab
|
|
): Array<{ type: 'ai' | 'file'; id: string; data: AITab | FilePreviewTab }> => [
|
|
{ type: 'ai' as const, id: aiTab.id, data: aiTab },
|
|
...fileTabs.map((ft) => ({ type: 'file' as const, id: ft.id, data: ft })),
|
|
];
|
|
|
|
it('renders 15 file tabs without performance issues', () => {
|
|
const aiTab = createTab({ id: 'ai-tab-1', name: 'AI Tab', agentSessionId: 'sess-1' });
|
|
const fileTabs = createManyFileTabs(15);
|
|
const unifiedTabs = createUnifiedTabsFromFiles(fileTabs, aiTab);
|
|
|
|
render(
|
|
<TabBar
|
|
tabs={[aiTab]}
|
|
activeTabId="ai-tab-1"
|
|
theme={mockTheme}
|
|
onTabSelect={mockOnTabSelect}
|
|
onTabClose={mockOnTabClose}
|
|
onNewTab={mockOnNewTab}
|
|
unifiedTabs={unifiedTabs}
|
|
activeFileTabId={null}
|
|
onFileTabSelect={mockOnFileTabSelect}
|
|
onFileTabClose={mockOnFileTabClose}
|
|
onUnifiedTabReorder={mockOnUnifiedTabReorder}
|
|
/>
|
|
);
|
|
|
|
// All 15 file tabs should be rendered
|
|
expect(screen.getByText('file-0')).toBeInTheDocument();
|
|
expect(screen.getByText('file-7')).toBeInTheDocument();
|
|
expect(screen.getByText('file-14')).toBeInTheDocument();
|
|
|
|
// All extension badges should be present (uppercase, no leading dot)
|
|
const tsBadges = screen.getAllByText('TS');
|
|
expect(tsBadges.length).toBe(15);
|
|
});
|
|
|
|
it('renders 30 file tabs with mixed AI tabs', () => {
|
|
const aiTab1 = createTab({ id: 'ai-tab-1', name: 'AI Tab 1', agentSessionId: 'sess-1' });
|
|
const aiTab2 = createTab({ id: 'ai-tab-2', name: 'AI Tab 2', agentSessionId: 'sess-2' });
|
|
const fileTabs = createManyFileTabs(30);
|
|
|
|
// Interleave AI tabs with file tabs
|
|
const unifiedTabs = [
|
|
{ type: 'ai' as const, id: aiTab1.id, data: aiTab1 },
|
|
...fileTabs.slice(0, 15).map((ft) => ({ type: 'file' as const, id: ft.id, data: ft })),
|
|
{ type: 'ai' as const, id: aiTab2.id, data: aiTab2 },
|
|
...fileTabs.slice(15).map((ft) => ({ type: 'file' as const, id: ft.id, data: ft })),
|
|
];
|
|
|
|
render(
|
|
<TabBar
|
|
tabs={[aiTab1, aiTab2]}
|
|
activeTabId="ai-tab-1"
|
|
theme={mockTheme}
|
|
onTabSelect={mockOnTabSelect}
|
|
onTabClose={mockOnTabClose}
|
|
onNewTab={mockOnNewTab}
|
|
unifiedTabs={unifiedTabs}
|
|
activeFileTabId={null}
|
|
onFileTabSelect={mockOnFileTabSelect}
|
|
onFileTabClose={mockOnFileTabClose}
|
|
onUnifiedTabReorder={mockOnUnifiedTabReorder}
|
|
/>
|
|
);
|
|
|
|
// AI tabs should be present
|
|
expect(screen.getByText('AI Tab 1')).toBeInTheDocument();
|
|
expect(screen.getByText('AI Tab 2')).toBeInTheDocument();
|
|
|
|
// File tabs from both groups should be present
|
|
expect(screen.getByText('file-0')).toBeInTheDocument();
|
|
expect(screen.getByText('file-14')).toBeInTheDocument();
|
|
expect(screen.getByText('file-15')).toBeInTheDocument();
|
|
expect(screen.getByText('file-29')).toBeInTheDocument();
|
|
});
|
|
|
|
it('selects file tab correctly among many tabs', () => {
|
|
const aiTab = createTab({ id: 'ai-tab-1', name: 'AI Tab', agentSessionId: 'sess-1' });
|
|
const fileTabs = createManyFileTabs(20);
|
|
const unifiedTabs = createUnifiedTabsFromFiles(fileTabs, aiTab);
|
|
|
|
render(
|
|
<TabBar
|
|
tabs={[aiTab]}
|
|
activeTabId="ai-tab-1"
|
|
theme={mockTheme}
|
|
onTabSelect={mockOnTabSelect}
|
|
onTabClose={mockOnTabClose}
|
|
onNewTab={mockOnNewTab}
|
|
unifiedTabs={unifiedTabs}
|
|
activeFileTabId={null}
|
|
onFileTabSelect={mockOnFileTabSelect}
|
|
onFileTabClose={mockOnFileTabClose}
|
|
onUnifiedTabReorder={mockOnUnifiedTabReorder}
|
|
/>
|
|
);
|
|
|
|
// Click on file-10
|
|
const fileTab10 = screen.getByText('file-10').closest('[data-tab-id]')!;
|
|
fireEvent.click(fileTab10);
|
|
|
|
expect(mockOnFileTabSelect).toHaveBeenCalledWith('file-tab-10');
|
|
});
|
|
|
|
it('closes file tab correctly among many tabs', () => {
|
|
const aiTab = createTab({ id: 'ai-tab-1', name: 'AI Tab', agentSessionId: 'sess-1' });
|
|
const fileTabs = createManyFileTabs(20);
|
|
const unifiedTabs = createUnifiedTabsFromFiles(fileTabs, aiTab);
|
|
|
|
render(
|
|
<TabBar
|
|
tabs={[aiTab]}
|
|
activeTabId="ai-tab-1"
|
|
theme={mockTheme}
|
|
onTabSelect={mockOnTabSelect}
|
|
onTabClose={mockOnTabClose}
|
|
onNewTab={mockOnNewTab}
|
|
unifiedTabs={unifiedTabs}
|
|
activeFileTabId="file-tab-5" // Make file-5 active to show close button
|
|
onFileTabSelect={mockOnFileTabSelect}
|
|
onFileTabClose={mockOnFileTabClose}
|
|
onUnifiedTabReorder={mockOnUnifiedTabReorder}
|
|
/>
|
|
);
|
|
|
|
// The close button should be visible on the active file tab
|
|
const fileTab5 = screen.getByText('file-5').closest('[data-tab-id]')!;
|
|
const closeButton = fileTab5.querySelector('button[title="Close tab"]');
|
|
expect(closeButton).toBeInTheDocument();
|
|
|
|
fireEvent.click(closeButton!);
|
|
expect(mockOnFileTabClose).toHaveBeenCalledWith('file-tab-5');
|
|
});
|
|
|
|
it('supports drag and drop reorder with many file tabs', () => {
|
|
const aiTab = createTab({ id: 'ai-tab-1', name: 'AI Tab', agentSessionId: 'sess-1' });
|
|
const fileTabs = createManyFileTabs(15);
|
|
const unifiedTabs = createUnifiedTabsFromFiles(fileTabs, aiTab);
|
|
|
|
render(
|
|
<TabBar
|
|
tabs={[aiTab]}
|
|
activeTabId="ai-tab-1"
|
|
theme={mockTheme}
|
|
onTabSelect={mockOnTabSelect}
|
|
onTabClose={mockOnTabClose}
|
|
onNewTab={mockOnNewTab}
|
|
unifiedTabs={unifiedTabs}
|
|
activeFileTabId={null}
|
|
onFileTabSelect={mockOnFileTabSelect}
|
|
onFileTabClose={mockOnFileTabClose}
|
|
onUnifiedTabReorder={mockOnUnifiedTabReorder}
|
|
/>
|
|
);
|
|
|
|
const fileTab2 = screen.getByText('file-2').closest('[data-tab-id]')!;
|
|
const fileTab10 = screen.getByText('file-10').closest('[data-tab-id]')!;
|
|
|
|
// Start dragging file-tab-2 (index 3 in unified tabs: AI tab is at 0)
|
|
fireEvent.dragStart(fileTab2, {
|
|
dataTransfer: {
|
|
effectAllowed: '',
|
|
setData: vi.fn(),
|
|
getData: vi.fn().mockReturnValue('file-tab-2'),
|
|
},
|
|
});
|
|
|
|
// Drop on file-tab-10 (index 11 in unified tabs)
|
|
fireEvent.drop(fileTab10, {
|
|
dataTransfer: {
|
|
getData: vi.fn().mockReturnValue('file-tab-2'),
|
|
},
|
|
});
|
|
|
|
// Should call onUnifiedTabReorder with correct indices
|
|
expect(mockOnUnifiedTabReorder).toHaveBeenCalledWith(3, 11);
|
|
});
|
|
|
|
it('renders file tabs with different extensions correctly', () => {
|
|
const aiTab = createTab({ id: 'ai-tab-1', name: 'AI Tab', agentSessionId: 'sess-1' });
|
|
const extensions = ['.ts', '.tsx', '.js', '.json', '.md', '.css', '.html', '.py', '.rs', '.go', '.sh'];
|
|
const fileTabs: FilePreviewTab[] = extensions.map((ext, i) => ({
|
|
id: `file-tab-${i}`,
|
|
path: `/path/to/files/file-${i}${ext}`,
|
|
name: `file-${i}`,
|
|
extension: ext,
|
|
content: `// Content`,
|
|
scrollTop: 0,
|
|
searchQuery: '',
|
|
editMode: false,
|
|
editContent: undefined,
|
|
createdAt: Date.now() + i,
|
|
lastModified: Date.now() + i,
|
|
}));
|
|
|
|
const unifiedTabs = createUnifiedTabsFromFiles(fileTabs, aiTab);
|
|
|
|
render(
|
|
<TabBar
|
|
tabs={[aiTab]}
|
|
activeTabId="ai-tab-1"
|
|
theme={mockTheme}
|
|
onTabSelect={mockOnTabSelect}
|
|
onTabClose={mockOnTabClose}
|
|
onNewTab={mockOnNewTab}
|
|
unifiedTabs={unifiedTabs}
|
|
activeFileTabId={null}
|
|
onFileTabSelect={mockOnFileTabSelect}
|
|
onFileTabClose={mockOnFileTabClose}
|
|
onUnifiedTabReorder={mockOnUnifiedTabReorder}
|
|
/>
|
|
);
|
|
|
|
// All extension badges should be rendered (uppercase, no leading dot)
|
|
extensions.forEach((ext) => {
|
|
// Strip leading dot and convert to uppercase (e.g., '.ts' -> 'TS')
|
|
const badgeText = ext.replace(/^\./, '').toUpperCase();
|
|
expect(screen.getByText(badgeText)).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it('maintains active tab styling among many tabs', () => {
|
|
const aiTab = createTab({ id: 'ai-tab-1', name: 'AI Tab', agentSessionId: 'sess-1' });
|
|
const fileTabs = createManyFileTabs(20);
|
|
const unifiedTabs = createUnifiedTabsFromFiles(fileTabs, aiTab);
|
|
|
|
render(
|
|
<TabBar
|
|
tabs={[aiTab]}
|
|
activeTabId="ai-tab-1"
|
|
theme={mockTheme}
|
|
onTabSelect={mockOnTabSelect}
|
|
onTabClose={mockOnTabClose}
|
|
onNewTab={mockOnNewTab}
|
|
unifiedTabs={unifiedTabs}
|
|
activeFileTabId="file-tab-10" // file-10 is active
|
|
onFileTabSelect={mockOnFileTabSelect}
|
|
onFileTabClose={mockOnFileTabClose}
|
|
onUnifiedTabReorder={mockOnUnifiedTabReorder}
|
|
/>
|
|
);
|
|
|
|
// Active file tab should have main background color (non-transparent)
|
|
const activeFileTab = screen.getByText('file-10').closest('[data-tab-id]')!;
|
|
expect(activeFileTab).toHaveStyle({ backgroundColor: mockTheme.colors.bgMain });
|
|
|
|
// Active file tab should also have the bottom margin adjustment (active styling)
|
|
expect(activeFileTab).toHaveStyle({ marginBottom: '-1px' });
|
|
|
|
// Inactive file tab should NOT have the active margin adjustment
|
|
const inactiveFileTab = screen.getByText('file-5').closest('[data-tab-id]')!;
|
|
expect(inactiveFileTab).toHaveStyle({ marginBottom: '0' });
|
|
});
|
|
});
|