mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-15 12:53:31 +08:00
Contributors and coding agents need one local command that both reports and enforces the quality contract. This change turns the PR gate into the shared verification entrypoint, adds path-selected local lanes, tightens coverage accounting around changed lines, and documents the repair loop in contributor and agent-facing guidance. Constraint: Ordinary PR verification must stay non-live and runnable without provider credentials Constraint: Coverage policy updates in this commit require maintainer approval before push/merge Rejected: Keep quality guidance only in docs | agents need executable scripts and AGENTS.md instructions to follow the loop consistently Confidence: high Scope-risk: broad Directive: Do not bypass `bun run verify` for production changes; fix failed lanes and coverage reports instead of lowering thresholds Tested: bun run check:policy Tested: ALLOW_CLI_CORE_CHANGE=1 ALLOW_COVERAGE_BASELINE_CHANGE=1 bun run verify Not-tested: live provider baseline; no provider credentials were required for this non-live PR gate
543 lines
18 KiB
TypeScript
543 lines
18 KiB
TypeScript
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import '@testing-library/jest-dom'
|
|
|
|
const startDraggingMock = vi.hoisted(() => vi.fn(() => Promise.resolve()))
|
|
const getCurrentWindowMock = vi.hoisted(() => vi.fn(() => ({
|
|
startDragging: startDraggingMock,
|
|
})))
|
|
const windowControlsMock = vi.hoisted(() => ({
|
|
show: true,
|
|
}))
|
|
|
|
vi.mock('@tauri-apps/api/window', () => ({
|
|
getCurrentWindow: getCurrentWindowMock,
|
|
}))
|
|
|
|
vi.mock('../../i18n', () => ({
|
|
useTranslation: () => (key: string) => {
|
|
const translations: Record<string, string> = {
|
|
'tabs.close': 'Close',
|
|
'tabs.closeOthers': 'Close Others',
|
|
'tabs.closeLeft': 'Close Left',
|
|
'tabs.closeRight': 'Close Right',
|
|
'tabs.closeAll': 'Close All',
|
|
'tabs.closeConfirmTitle': 'Session Running',
|
|
'tabs.closeConfirmMessage': 'Still running',
|
|
'tabs.closeConfirmKeep': 'Keep Running',
|
|
'tabs.closeConfirmStop': 'Stop & Close',
|
|
'tabs.openTerminal': 'Open Terminal',
|
|
'tabs.showWorkspace': 'Show Workspace',
|
|
'tabs.hideWorkspace': 'Hide Workspace',
|
|
'common.cancel': 'Cancel',
|
|
}
|
|
|
|
return translations[key] ?? key
|
|
},
|
|
}))
|
|
|
|
vi.mock('./WindowControls', () => ({
|
|
WindowControls: () => (windowControlsMock.show ? <div data-testid="window-controls" /> : null),
|
|
get showWindowControls() {
|
|
return windowControlsMock.show
|
|
},
|
|
}))
|
|
|
|
describe('TabBar', () => {
|
|
beforeEach(() => {
|
|
class ResizeObserverMock {
|
|
constructor(_callback: ResizeObserverCallback) {}
|
|
|
|
observe(_target: Element) {}
|
|
|
|
disconnect() {}
|
|
unobserve() {}
|
|
}
|
|
|
|
Object.defineProperty(window, 'ResizeObserver', {
|
|
configurable: true,
|
|
value: ResizeObserverMock,
|
|
})
|
|
|
|
Object.defineProperty(window, '__TAURI__', {
|
|
configurable: true,
|
|
value: {},
|
|
})
|
|
|
|
startDraggingMock.mockClear()
|
|
getCurrentWindowMock.mockClear()
|
|
windowControlsMock.show = true
|
|
vi.resetModules()
|
|
})
|
|
|
|
afterEach(async () => {
|
|
cleanup()
|
|
|
|
const { useTabStore } = await import('../../stores/tabStore')
|
|
const { useChatStore } = await import('../../stores/chatStore')
|
|
const { useWorkspacePanelStore } = await import('../../stores/workspacePanelStore')
|
|
const { useTerminalPanelStore } = await import('../../stores/terminalPanelStore')
|
|
|
|
useTabStore.setState({ tabs: [], activeTabId: null })
|
|
useChatStore.setState({
|
|
sessions: {},
|
|
} as Partial<ReturnType<typeof useChatStore.getState>>)
|
|
useWorkspacePanelStore.setState(useWorkspacePanelStore.getInitialState(), true)
|
|
useTerminalPanelStore.setState(useTerminalPanelStore.getInitialState(), true)
|
|
|
|
delete (window as typeof window & { __TAURI__?: unknown }).__TAURI__
|
|
})
|
|
|
|
it('keeps the overflow button flush against window controls on Windows', async () => {
|
|
const { TabBar } = await import('./TabBar')
|
|
const { useTabStore } = await import('../../stores/tabStore')
|
|
const { useChatStore } = await import('../../stores/chatStore')
|
|
|
|
useTabStore.setState({
|
|
tabs: [
|
|
{ sessionId: 'tab-1', title: 'Untitled Session', type: 'session', status: 'idle' },
|
|
{ sessionId: 'tab-2', title: 'Settings', type: 'settings', status: 'idle' },
|
|
{ sessionId: 'tab-3', title: 'hello', type: 'session', status: 'idle' },
|
|
{ sessionId: 'tab-4', title: 'overflow', type: 'session', status: 'idle' },
|
|
],
|
|
activeTabId: 'tab-1',
|
|
})
|
|
useChatStore.setState({
|
|
sessions: {},
|
|
disconnectSession: vi.fn(),
|
|
} as Partial<ReturnType<typeof useChatStore.getState>>)
|
|
|
|
await act(async () => {
|
|
render(<TabBar />)
|
|
})
|
|
|
|
const scrollRegion = screen.getByTestId('tab-bar').querySelector('.overflow-x-hidden')
|
|
expect(scrollRegion).toBeInTheDocument()
|
|
|
|
Object.defineProperty(scrollRegion!, 'clientWidth', {
|
|
configurable: true,
|
|
get: () => 240,
|
|
})
|
|
Object.defineProperty(scrollRegion!, 'scrollWidth', {
|
|
configurable: true,
|
|
get: () => 720,
|
|
})
|
|
Object.defineProperty(scrollRegion!, 'scrollLeft', {
|
|
configurable: true,
|
|
get: () => 0,
|
|
})
|
|
Object.defineProperty(scrollRegion!, 'scrollBy', {
|
|
configurable: true,
|
|
value: vi.fn(),
|
|
})
|
|
|
|
act(() => {
|
|
fireEvent.scroll(scrollRegion!)
|
|
})
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('window-controls')).toBeInTheDocument()
|
|
expect(screen.getByText('chevron_right').closest('button')).toBeInTheDocument()
|
|
})
|
|
|
|
const rightButton = screen.getByText('chevron_right').closest('button')
|
|
expect(rightButton?.nextElementSibling).toBe(screen.getByTestId('window-controls'))
|
|
})
|
|
|
|
it('shows the terminal toolbar when no tabs are open', async () => {
|
|
windowControlsMock.show = false
|
|
const { TabBar } = await import('./TabBar')
|
|
const { useTabStore } = await import('../../stores/tabStore')
|
|
|
|
useTabStore.setState({ tabs: [], activeTabId: null })
|
|
|
|
await act(async () => {
|
|
render(<TabBar />)
|
|
})
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Open Terminal' }))
|
|
|
|
const terminalTabs = useTabStore.getState().tabs.filter((tab) => tab.type === 'terminal')
|
|
expect(terminalTabs).toHaveLength(1)
|
|
expect(useTabStore.getState().activeTabId).toBe(terminalTabs[0]?.sessionId)
|
|
expect(screen.queryByTestId('window-controls')).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('marks the tab bar as a native drag region', async () => {
|
|
const { TabBar } = await import('./TabBar')
|
|
const { useTabStore } = await import('../../stores/tabStore')
|
|
const { useChatStore } = await import('../../stores/chatStore')
|
|
|
|
useTabStore.setState({
|
|
tabs: [
|
|
{ sessionId: 'tab-1', title: 'Untitled Session', type: 'session', status: 'idle' },
|
|
],
|
|
activeTabId: 'tab-1',
|
|
})
|
|
useChatStore.setState({
|
|
sessions: {},
|
|
disconnectSession: vi.fn(),
|
|
} as Partial<ReturnType<typeof useChatStore.getState>>)
|
|
|
|
await act(async () => {
|
|
render(<TabBar />)
|
|
})
|
|
|
|
expect(screen.getByTestId('tab-bar')).not.toHaveAttribute('data-tauri-drag-region')
|
|
expect(screen.getByTestId('tab-bar-drag-gutter')).toHaveAttribute('data-tauri-drag-region')
|
|
})
|
|
|
|
it('starts dragging when clicking the empty tab-bar gutter', async () => {
|
|
const { TabBar } = await import('./TabBar')
|
|
const { useTabStore } = await import('../../stores/tabStore')
|
|
const { useChatStore } = await import('../../stores/chatStore')
|
|
|
|
useTabStore.setState({
|
|
tabs: [
|
|
{ sessionId: 'tab-1', title: 'Untitled Session', type: 'session', status: 'idle' },
|
|
],
|
|
activeTabId: 'tab-1',
|
|
})
|
|
useChatStore.setState({
|
|
sessions: {},
|
|
disconnectSession: vi.fn(),
|
|
} as Partial<ReturnType<typeof useChatStore.getState>>)
|
|
|
|
await act(async () => {
|
|
render(<TabBar />)
|
|
})
|
|
|
|
await waitFor(() => {
|
|
expect(getCurrentWindowMock).toHaveBeenCalled()
|
|
})
|
|
|
|
const scrollRegion = screen.getByTestId('tab-bar').querySelector('.overflow-x-hidden')
|
|
expect(scrollRegion).toBeInTheDocument()
|
|
|
|
fireEvent.mouseDown(scrollRegion!)
|
|
|
|
await waitFor(() => {
|
|
expect(startDraggingMock).toHaveBeenCalledTimes(1)
|
|
})
|
|
})
|
|
|
|
it('does not start dragging when clicking a tab', async () => {
|
|
const { TabBar } = await import('./TabBar')
|
|
const { useTabStore } = await import('../../stores/tabStore')
|
|
const { useChatStore } = await import('../../stores/chatStore')
|
|
|
|
useTabStore.setState({
|
|
tabs: [
|
|
{ sessionId: 'tab-1', title: 'Untitled Session', type: 'session', status: 'idle' },
|
|
],
|
|
activeTabId: 'tab-1',
|
|
})
|
|
useChatStore.setState({
|
|
sessions: {},
|
|
disconnectSession: vi.fn(),
|
|
} as Partial<ReturnType<typeof useChatStore.getState>>)
|
|
|
|
await act(async () => {
|
|
render(<TabBar />)
|
|
})
|
|
|
|
await waitFor(() => {
|
|
expect(getCurrentWindowMock).toHaveBeenCalled()
|
|
})
|
|
|
|
fireEvent.mouseDown(screen.getByText('Untitled Session'))
|
|
|
|
expect(startDraggingMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('reorders tabs via pointer drag', async () => {
|
|
const { TabBar } = await import('./TabBar')
|
|
const { useTabStore } = await import('../../stores/tabStore')
|
|
const { useChatStore } = await import('../../stores/chatStore')
|
|
|
|
useTabStore.setState({
|
|
tabs: [
|
|
{ sessionId: 'tab-1', title: 'First Session', type: 'session', status: 'idle' },
|
|
{ sessionId: 'tab-2', title: 'Second Session', type: 'session', status: 'idle' },
|
|
],
|
|
activeTabId: 'tab-1',
|
|
})
|
|
useChatStore.setState({
|
|
sessions: {},
|
|
disconnectSession: vi.fn(),
|
|
} as Partial<ReturnType<typeof useChatStore.getState>>)
|
|
|
|
await act(async () => {
|
|
render(<TabBar />)
|
|
})
|
|
|
|
expect(screen.getByTestId('tab-bar').querySelector('.tab-bar-hit-area')).toBeInTheDocument()
|
|
|
|
const firstTab = screen.getByText('First Session').closest('.tab-bar-hit-area')
|
|
const secondTab = screen.getByText('Second Session').closest('.tab-bar-hit-area')
|
|
|
|
expect(firstTab).toBeTruthy()
|
|
expect(secondTab).toBeTruthy()
|
|
|
|
Object.defineProperty(firstTab!, 'getBoundingClientRect', {
|
|
configurable: true,
|
|
value: () => ({ left: 0, width: 180 }),
|
|
})
|
|
Object.defineProperty(secondTab!, 'getBoundingClientRect', {
|
|
configurable: true,
|
|
value: () => ({ left: 180, width: 180 }),
|
|
})
|
|
|
|
fireEvent.mouseDown(firstTab!, { button: 0, clientX: 20, clientY: 10 })
|
|
fireEvent.mouseMove(window, { clientX: 260, clientY: 10 })
|
|
|
|
expect(firstTab).toHaveAttribute('data-dragging', 'true')
|
|
|
|
fireEvent.mouseUp(window)
|
|
|
|
expect(useTabStore.getState().tabs.map((tab) => tab.sessionId)).toEqual(['tab-2', 'tab-1'])
|
|
})
|
|
|
|
it('does not reorder on a simple click without dragging', async () => {
|
|
const { TabBar } = await import('./TabBar')
|
|
const { useTabStore } = await import('../../stores/tabStore')
|
|
const { useChatStore } = await import('../../stores/chatStore')
|
|
|
|
useTabStore.setState({
|
|
tabs: [
|
|
{ sessionId: 'tab-1', title: 'First Session', type: 'session', status: 'idle' },
|
|
{ sessionId: 'tab-2', title: 'Second Session', type: 'session', status: 'idle' },
|
|
],
|
|
activeTabId: 'tab-1',
|
|
})
|
|
useChatStore.setState({
|
|
sessions: {},
|
|
disconnectSession: vi.fn(),
|
|
} as Partial<ReturnType<typeof useChatStore.getState>>)
|
|
|
|
await act(async () => {
|
|
render(<TabBar />)
|
|
})
|
|
|
|
const firstTab = screen.getByText('First Session').closest('.tab-bar-hit-area')
|
|
expect(firstTab).toBeTruthy()
|
|
|
|
fireEvent.mouseDown(firstTab!, { button: 0, clientX: 20, clientY: 10 })
|
|
fireEvent.mouseUp(window)
|
|
fireEvent.click(firstTab!)
|
|
|
|
expect(useTabStore.getState().tabs.map((tab) => tab.sessionId)).toEqual(['tab-1', 'tab-2'])
|
|
expect(useTabStore.getState().activeTabId).toBe('tab-1')
|
|
})
|
|
|
|
it('closes a tab from the close button without activating drag behavior', async () => {
|
|
const { TabBar } = await import('./TabBar')
|
|
const { useTabStore } = await import('../../stores/tabStore')
|
|
const { useChatStore } = await import('../../stores/chatStore')
|
|
|
|
const disconnectSession = vi.fn()
|
|
|
|
useTabStore.setState({
|
|
tabs: [
|
|
{ sessionId: 'tab-1', title: 'First Session', type: 'session', status: 'idle' },
|
|
{ sessionId: 'tab-2', title: 'Second Session', type: 'session', status: 'idle' },
|
|
],
|
|
activeTabId: 'tab-2',
|
|
})
|
|
useChatStore.setState({
|
|
sessions: {},
|
|
disconnectSession,
|
|
} as Partial<ReturnType<typeof useChatStore.getState>>)
|
|
|
|
await act(async () => {
|
|
render(<TabBar />)
|
|
})
|
|
|
|
const firstTab = screen.getByText('First Session').closest('.tab-bar-hit-area')
|
|
const closeButton = screen.getByLabelText('Close First Session')
|
|
|
|
expect(firstTab).toHaveClass('group')
|
|
|
|
fireEvent.mouseDown(closeButton, { button: 0, clientX: 20, clientY: 10 })
|
|
fireEvent.click(closeButton)
|
|
fireEvent.mouseMove(window, { clientX: 260, clientY: 10 })
|
|
fireEvent.mouseUp(window)
|
|
|
|
expect(disconnectSession).toHaveBeenCalledWith('tab-1')
|
|
expect(useTabStore.getState().tabs.map((tab) => tab.sessionId)).toEqual(['tab-2'])
|
|
expect(useTabStore.getState().activeTabId).toBe('tab-2')
|
|
})
|
|
|
|
it('closes terminal tabs without disconnecting chat sessions', async () => {
|
|
const { TabBar } = await import('./TabBar')
|
|
const { useTabStore } = await import('../../stores/tabStore')
|
|
const { useChatStore } = await import('../../stores/chatStore')
|
|
|
|
const disconnectSession = vi.fn()
|
|
|
|
useTabStore.setState({
|
|
tabs: [
|
|
{ sessionId: '__terminal__1', title: 'Terminal 1', type: 'terminal', status: 'idle' },
|
|
],
|
|
activeTabId: '__terminal__1',
|
|
})
|
|
useChatStore.setState({
|
|
sessions: {},
|
|
disconnectSession,
|
|
} as Partial<ReturnType<typeof useChatStore.getState>>)
|
|
|
|
await act(async () => {
|
|
render(<TabBar />)
|
|
})
|
|
|
|
expect(screen.getByLabelText('Open Terminal')).toBeInTheDocument()
|
|
|
|
fireEvent.click(screen.getByLabelText('Close Terminal 1'))
|
|
|
|
expect(disconnectSession).not.toHaveBeenCalled()
|
|
expect(useTabStore.getState().tabs).toEqual([])
|
|
})
|
|
|
|
it('opens the bottom terminal panel from the toolbar for an active session', async () => {
|
|
const { TabBar } = await import('./TabBar')
|
|
const { useTabStore } = await import('../../stores/tabStore')
|
|
const { useChatStore } = await import('../../stores/chatStore')
|
|
const { useTerminalPanelStore } = await import('../../stores/terminalPanelStore')
|
|
|
|
useTabStore.setState({
|
|
tabs: [
|
|
{ sessionId: 'tab-1', title: 'First Session', type: 'session', status: 'idle' },
|
|
],
|
|
activeTabId: 'tab-1',
|
|
})
|
|
useChatStore.setState({
|
|
sessions: {},
|
|
disconnectSession: vi.fn(),
|
|
} as Partial<ReturnType<typeof useChatStore.getState>>)
|
|
|
|
await act(async () => {
|
|
render(<TabBar />)
|
|
})
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Open Terminal' }))
|
|
|
|
const terminalTabs = useTabStore.getState().tabs.filter((tab) => tab.type === 'terminal')
|
|
expect(terminalTabs).toHaveLength(0)
|
|
expect(useTerminalPanelStore.getState().isPanelOpen('tab-1')).toBe(true)
|
|
})
|
|
|
|
it('treats legacy session tabs without a type as bottom-panel terminal targets', async () => {
|
|
const { TabBar } = await import('./TabBar')
|
|
const { useTabStore } = await import('../../stores/tabStore')
|
|
const { useChatStore } = await import('../../stores/chatStore')
|
|
const { useTerminalPanelStore } = await import('../../stores/terminalPanelStore')
|
|
|
|
useTabStore.setState({
|
|
tabs: [
|
|
{ sessionId: 'legacy-session', title: 'Legacy Session', status: 'idle' } as ReturnType<typeof useTabStore.getState>['tabs'][number],
|
|
],
|
|
activeTabId: 'legacy-session',
|
|
})
|
|
useChatStore.setState({
|
|
sessions: {},
|
|
disconnectSession: vi.fn(),
|
|
} as Partial<ReturnType<typeof useChatStore.getState>>)
|
|
|
|
await act(async () => {
|
|
render(<TabBar />)
|
|
})
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Open Terminal' }))
|
|
|
|
expect(useTabStore.getState().tabs.some((tab) => tab.type === 'terminal')).toBe(false)
|
|
expect(useTerminalPanelStore.getState().isPanelOpen('legacy-session')).toBe(true)
|
|
})
|
|
|
|
it('toggles the workspace panel for the active session from the toolbar', async () => {
|
|
const { TabBar } = await import('./TabBar')
|
|
const { useTabStore } = await import('../../stores/tabStore')
|
|
const { useChatStore } = await import('../../stores/chatStore')
|
|
const { useWorkspacePanelStore } = await import('../../stores/workspacePanelStore')
|
|
|
|
useTabStore.setState({
|
|
tabs: [
|
|
{ sessionId: 'tab-1', title: 'First Session', type: 'session', status: 'idle' },
|
|
],
|
|
activeTabId: 'tab-1',
|
|
})
|
|
useChatStore.setState({
|
|
sessions: {},
|
|
disconnectSession: vi.fn(),
|
|
} as Partial<ReturnType<typeof useChatStore.getState>>)
|
|
|
|
await act(async () => {
|
|
render(<TabBar />)
|
|
})
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Show Workspace' }))
|
|
expect(useWorkspacePanelStore.getState().isPanelOpen('tab-1')).toBe(true)
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Hide Workspace' }))
|
|
expect(useWorkspacePanelStore.getState().isPanelOpen('tab-1')).toBe(false)
|
|
})
|
|
|
|
it('hides the workspace toolbar button for non-session tabs', async () => {
|
|
const { TabBar } = await import('./TabBar')
|
|
const { useTabStore } = await import('../../stores/tabStore')
|
|
const { useChatStore } = await import('../../stores/chatStore')
|
|
|
|
useTabStore.setState({
|
|
tabs: [
|
|
{ sessionId: '__terminal__1', title: 'Terminal 1', type: 'terminal', status: 'idle' },
|
|
{ sessionId: '__settings__', title: 'Settings', type: 'settings', status: 'idle' },
|
|
],
|
|
activeTabId: '__terminal__1',
|
|
})
|
|
useChatStore.setState({
|
|
sessions: {},
|
|
disconnectSession: vi.fn(),
|
|
} as Partial<ReturnType<typeof useChatStore.getState>>)
|
|
|
|
const { rerender } = render(<TabBar />)
|
|
|
|
expect(screen.queryByRole('button', { name: 'Show Workspace' })).not.toBeInTheDocument()
|
|
|
|
await act(async () => {
|
|
useTabStore.getState().setActiveTab('__settings__')
|
|
})
|
|
rerender(<TabBar />)
|
|
|
|
expect(screen.queryByRole('button', { name: 'Show Workspace' })).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('clears session panel state when closing a session tab', async () => {
|
|
const { TabBar } = await import('./TabBar')
|
|
const { useTabStore } = await import('../../stores/tabStore')
|
|
const { useChatStore } = await import('../../stores/chatStore')
|
|
const { useWorkspacePanelStore } = await import('../../stores/workspacePanelStore')
|
|
const { useTerminalPanelStore } = await import('../../stores/terminalPanelStore')
|
|
|
|
useTabStore.setState({
|
|
tabs: [
|
|
{ sessionId: 'tab-1', title: 'First Session', type: 'session', status: 'idle' },
|
|
],
|
|
activeTabId: 'tab-1',
|
|
})
|
|
useChatStore.setState({
|
|
sessions: {},
|
|
disconnectSession: vi.fn(),
|
|
} as Partial<ReturnType<typeof useChatStore.getState>>)
|
|
useWorkspacePanelStore.getState().openPanel('tab-1')
|
|
useTerminalPanelStore.getState().openPanel('tab-1')
|
|
|
|
await act(async () => {
|
|
render(<TabBar />)
|
|
})
|
|
|
|
fireEvent.click(screen.getByLabelText('Close First Session'))
|
|
|
|
expect(useWorkspacePanelStore.getState().panelBySession['tab-1']).toBeUndefined()
|
|
expect(useTerminalPanelStore.getState().panelBySession['tab-1']).toBeUndefined()
|
|
})
|
|
})
|