Files
apes-sidebar/tests/tab-bar-context-menu.spec.tsx
T
Menghuan1918 223b2545d9 refactor(shell): retire the plugin's own right panel and free windows
DSH 0.1.5 owns the right column (its native Sidebar hosts every tab type
this plugin registers), so the plugin's own right panel, its width drag /
new-session seed prefs, and the free-window feature are gone. The bottom
workbench is the only plugin-owned surface left, and its expand/collapse
control is registered into DSH's session-header utilities list slot (the
header's corner seat belongs to the native sidebar's own control).

- state: one `bottomSplits` tree; `makeDefaultState()` takes no arguments;
  `toggleBottomPanel` / `openTabInBottomPane` are the only landing
  reducers; `setBottomHeight` and `sanitizeState` share the
  `CONVERSATION_MIN` cap (a persisted tall height no longer snaps on the
  first drag).
- service: every landing in the workbench (creation or focus) expands it;
  `SIDEBAR_FEATURES` drops `'floatWindows'`; the open/close/activate tab
  pools cover the single tree.
- shell: `Sidebar.tsx` renders only the bottom workbench; the toggle lives
  in `sidebar/bottom-toggle.tsx`; layout-push pushes height only (no width,
  no `--dsh-sidebar-width`).
- prefs: `openByDefault` / `defaultWidthPercent` / `changesDiffFloat`
  removed from the schema, defaults, client parser, settings page, and all
  20 dictionaries; the retired keys in stored documents are ignored.
- tests: free-window specs deleted, panel/float assertions dropped,
  remaining suites adapted (124 files / 1287 tests); the mount lane asserts
  the header toggle and sweeps the native sidebar's guide page.
- docs: README / README_EN / guide §0,§11 / AGENTS test map.
2026-09-09 15:18:06 +08:00

364 lines
13 KiB
TypeScript

/**
* Tab-strip right-click context-menu tests. Right-clicking a tab takes over
* the browser menu (preventDefault) and shows the tab context menu with
* exactly five items: move to free window / close / close others / close to
* the left / close to the right. All bulk operations are scoped to the
* CURRENT pane (the render time tab snapshot) and reuse the per-tab onClose
* path, so the target tab is never closed and the pane never empties
* mid-loop. The menu items gray out when there is nothing to close (single
* tab → close others; leftmost → close left; rightmost → close right).
* Opening the menu must not activate the right-clicked tab.
*/
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createElement } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { act } from 'react-dom/test-utils'
// The act() environment flag (React 18.2 reads it before flushing effects).
import { setupReactAct } from './test-utils.ts'
setupReactAct()
import { TabBar } from '../src/client/TabBar.tsx'
import type { SidebarTab } from '../src/client/state.ts'
/** Point the browser-language fallback at Chinese so the menu labels assert. */
function stubZh(): void {
Object.defineProperty(globalThis, 'navigator', {
value: { language: 'zh-CN' },
configurable: true,
})
}
const MENU_LABELS = ['关闭', '关闭其他页签', '关闭左侧页签', '关闭右侧页签']
function mountBar(tabs: SidebarTab[], opts: { onPinTab?: (tabId: string, scope: 'workspace' | 'global' | null) => void } = {}): {
tabEls: HTMLElement[]
onClose: ReturnType<typeof vi.fn>
onActivate: ReturnType<typeof vi.fn>
onPinTab?: (tabId: string, scope: 'workspace' | 'global' | null) => void
unmount: () => void
} {
const container = document.createElement('div')
document.body.append(container)
const onClose = vi.fn()
const onActivate = vi.fn()
const onPinTab = opts.onPinTab
const root: Root = createRoot(container)
act(() => {
root.render(createElement(TabBar, {
paneId: 'pane:1',
tabs,
active: tabs[0]?.id ?? null,
onActivate,
onClose,
onNewTab: () => {},
newTabOptions: [],
...(onPinTab !== undefined ? { onPinTab } : {}),
onDropTab: () => {},
}))
})
const tabEls = [...container.querySelectorAll('[class*="tabList"] > [class*="tab"]')] as HTMLElement[]
return {
tabEls,
onClose,
onActivate,
...(onPinTab !== undefined ? { onPinTab } : {}),
unmount: () => {
act(() => { root.unmount() })
container.remove()
},
}
}
function fourTabs(): SidebarTab[] {
return [
{ id: 't1', type: 'editor', title: 'Tab 1' },
{ id: 't2', type: 'git', title: 'Tab 2' },
{ id: 't3', type: 'terminal', title: 'Tab 3' },
{ id: 't4', type: 'browser', title: 'Tab 4' },
]
}
/** Dispatch a native right-click (contextmenu) and return the event. */
function rightClick(target: EventTarget): MouseEvent {
const event = new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 120, clientY: 40 })
target.dispatchEvent(event)
return event
}
/** The portaled menu rows (empty when the menu is closed). */
function menuItems(): HTMLElement[] {
return [...document.querySelectorAll<HTMLElement>('[role="menuitem"]')]
}
afterEach(() => {
document.body.innerHTML = ''
vi.restoreAllMocks()
})
describe('TabBar right-click context menu', () => {
it('opens the five-item menu at the cursor, prevents the browser menu, and does not activate', () => {
stubZh()
const { tabEls, onActivate, unmount } = mountBar(fourTabs())
try {
let event: MouseEvent | null = null
act(() => { event = rightClick(tabEls[1]!) })
expect(event!.defaultPrevented).toBe(true)
expect(menuItems().map(item => item.textContent)).toEqual(MENU_LABELS)
expect(onActivate).not.toHaveBeenCalled()
} finally {
unmount()
}
})
it('close closes the target tab and dismisses the menu', () => {
stubZh()
const { tabEls, onClose, unmount } = mountBar(fourTabs())
try {
act(() => { rightClick(tabEls[1]!) })
act(() => { menuItems()[0]!.click() })
expect(onClose).toHaveBeenCalledTimes(1)
expect(onClose).toHaveBeenCalledWith('t2')
expect(menuItems()).toHaveLength(0)
} finally {
unmount()
}
})
it('close closes only the target tab and closes the menu', () => {
stubZh()
const { tabEls, onClose, unmount } = mountBar(fourTabs())
try {
act(() => { rightClick(tabEls[1]!) })
act(() => { menuItems()[0]!.click() })
expect(onClose).toHaveBeenCalledTimes(1)
expect(onClose).toHaveBeenCalledWith('t2')
expect(menuItems()).toHaveLength(0)
} finally {
unmount()
}
})
it('close others closes every tab in the pane except the target, in visual order', () => {
stubZh()
const { tabEls, onClose, unmount } = mountBar(fourTabs())
try {
act(() => { rightClick(tabEls[1]!) })
act(() => { menuItems()[1]!.click() })
expect(onClose.mock.calls.map(call => call[0])).toEqual(['t1', 't3', 't4'])
expect(onClose).not.toHaveBeenCalledWith('t2')
expect(menuItems()).toHaveLength(0)
} finally {
unmount()
}
})
it('close left closes only the tabs to the left of the target', () => {
stubZh()
const { tabEls, onClose, unmount } = mountBar(fourTabs())
try {
act(() => { rightClick(tabEls[2]!) })
act(() => { menuItems()[2]!.click() })
expect(onClose.mock.calls.map(call => call[0])).toEqual(['t1', 't2'])
expect(menuItems()).toHaveLength(0)
} finally {
unmount()
}
})
it('close right closes only the tabs to the right of the target', () => {
stubZh()
const { tabEls, onClose, unmount } = mountBar(fourTabs())
try {
act(() => { rightClick(tabEls[1]!) })
act(() => { menuItems()[3]!.click() })
expect(onClose.mock.calls.map(call => call[0])).toEqual(['t3', 't4'])
expect(menuItems()).toHaveLength(0)
} finally {
unmount()
}
})
it('grays out close others on a single tab and close left/right at the strip ends', () => {
stubZh()
const single = mountBar([
{ id: 'only', type: 'editor', title: 'Only' },
])
try {
act(() => { rightClick(single.tabEls[0]!) })
const items = menuItems()
// The Menu renders each row as a disabled <button role="menuitem">.
expect(items.map(item => (item as HTMLButtonElement).disabled)).toEqual([false, true, true, true])
// Clicking the disabled row must not close anything.
act(() => { items[1]!.click() })
expect(single.onClose).not.toHaveBeenCalled()
} finally {
single.unmount()
}
const four = mountBar(fourTabs())
try {
act(() => { rightClick(four.tabEls[0]!) })
expect(menuItems().map(item => (item as HTMLButtonElement).disabled)).toEqual([false, false, true, false])
act(() => { rightClick(four.tabEls[3]!) })
expect(menuItems().map(item => (item as HTMLButtonElement).disabled)).toEqual([false, false, false, true])
} finally {
four.unmount()
}
})
})
describe('TabBar pin submenu (v0.17.0)', () => {
/** The portaled submenu rows (aria-expanded parent + nested menuitem rows). */
function submenuItems(): HTMLElement[] {
return [...document.querySelectorAll<HTMLElement>('[role="menuitemradio"], [role="menuitem"]')]
}
it('hides the pin entry when onPinTab is not provided (legacy callers)', () => {
stubZh()
const { tabEls, unmount } = mountBar(fourTabs())
try {
act(() => { rightClick(tabEls[2]!) }) // t3 = terminal
// Exactly the legacy close-only menu, no pin row.
expect(menuItems().map(item => item.textContent)).toEqual(MENU_LABELS)
} finally {
unmount()
}
})
it('adds a "Pin Terminal ▸" submenu for an unpinned UI terminal', () => {
stubZh()
const onPinTab = vi.fn<(tabId: string, scope: 'workspace' | 'global' | null) => void>()
const { tabEls, unmount } = mountBar(fourTabs(), { onPinTab })
try {
act(() => { rightClick(tabEls[2]!) }) // t3 = UI terminal
const labels = menuItems().map(item => item.textContent)
// pin (with submenu indicator) | close | closeOthers | closeLeft | closeRight
expect(labels[0]).toBe('固定终端')
expect(labels[1]).toBe('关闭')
// Hovering the pin row reveals the submenu items. React synthesizes
// onMouseEnter from the bubbling mouseover event, so dispatch that.
act(() => { menuItems()[0]!.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })) })
const sub = submenuItems().map(item => item.textContent)
expect(sub).toContain('固定到工作区')
expect(sub).toContain('固定到全局')
// Clicking "pin to workspace" fires the callback.
const wsItem = submenuItems().find(item => item.textContent === '固定到工作区')!
act(() => { wsItem.click() })
expect(onPinTab).toHaveBeenCalledWith('t3', 'workspace')
} finally {
unmount()
}
})
it('uses the "Pin Agent Terminal" label for an agent terminal tab', () => {
stubZh()
const onPinTab = vi.fn<(tabId: string, scope: 'workspace' | 'global' | null) => void>()
const tabs: SidebarTab[] = [
{ id: 'agent:abc-123', type: 'terminal', title: 'Agent T' },
]
const { tabEls, unmount } = mountBar(tabs, { onPinTab })
try {
act(() => { rightClick(tabEls[0]!) })
const labels = menuItems().map(item => item.textContent)
expect(labels[0]).toBe('固定 Agent 终端')
} finally {
unmount()
}
})
it('shows "Unpin" instead of the submenu for an already-pinned terminal', () => {
stubZh()
const onPinTab = vi.fn<(tabId: string, scope: 'workspace' | 'global' | null) => void>()
const tabs: SidebarTab[] = [
{ id: 'terminal:1', type: 'terminal', title: 'T', pin: { scope: 'global' } },
]
const { tabEls, unmount } = mountBar(tabs, { onPinTab })
try {
act(() => { rightClick(tabEls[0]!) })
const labels = menuItems().map(item => item.textContent)
expect(labels).toContain('取消固定')
expect(labels).not.toContain('固定终端')
const unpinItem = menuItems().find(item => item.textContent === '取消固定')!
act(() => { unpinItem.click() })
expect(onPinTab).toHaveBeenCalledWith('terminal:1', null)
} finally {
unmount()
}
})
it('does not add a pin entry for non-terminal tabs even with onPinTab provided', () => {
stubZh()
const onPinTab = vi.fn<(tabId: string, scope: 'workspace' | 'global' | null) => void>()
const { tabEls, unmount } = mountBar(fourTabs(), { onPinTab })
try {
act(() => { rightClick(tabEls[0]!) }) // t1 = editor
expect(menuItems().map(item => item.textContent)).toEqual(MENU_LABELS)
act(() => { rightClick(tabEls[1]!) }) // t2 = git
expect(menuItems().map(item => item.textContent)).toEqual(MENU_LABELS)
expect(onPinTab).not.toHaveBeenCalled()
} finally {
unmount()
}
})
})
describe('TabBar context menu flip geometry (submenu clamping)', () => {
const ATTR = 'data-dsh-sidebar-submenu'
/** jsdom's default viewport (what the hook reads via window.innerWidth/Height). */
const VW = window.innerWidth
const VH = window.innerHeight
it('publishes "down" while the menu is open and clears it on selection', () => {
stubZh()
const { tabEls, onClose, unmount } = mountBar(fourTabs())
try {
// rightClick() dispatches at clientY 40 — the top strip, upper half.
act(() => { rightClick(tabEls[1]!) })
expect(document.body.getAttribute(ATTR)).toBe('down')
act(() => { menuItems()[0]!.click() })
expect(onClose).toHaveBeenCalledWith('t2')
expect(document.body.hasAttribute(ATTR)).toBe(false)
} finally {
unmount()
}
})
it('flips left when the cursor sits within the right-hand submenu room', () => {
stubZh()
const { tabEls, unmount } = mountBar(fourTabs())
try {
const nearRight = new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: VW - 10, clientY: 40 })
act(() => { tabEls[1]!.dispatchEvent(nearRight) })
expect(document.body.getAttribute(ATTR)).toBe('down left')
} finally {
unmount()
}
})
it('keeps the primitive defaults (empty tokens) for a lower-half opening', () => {
stubZh()
const { tabEls, unmount } = mountBar(fourTabs())
try {
const lowLeft = new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 120, clientY: VH - 40 })
act(() => { tabEls[1]!.dispatchEvent(lowLeft) })
// Attribute present but empty: layout.css matches no token, so the
// host primitive's own up/right geometry applies.
expect(document.body.getAttribute(ATTR)).toBe('')
} finally {
unmount()
}
})
it('clears the attribute on unmount with the menu still open', () => {
stubZh()
const { tabEls, unmount } = mountBar(fourTabs())
act(() => { rightClick(tabEls[1]!) })
expect(document.body.getAttribute(ATTR)).toBe('down')
unmount()
expect(document.body.hasAttribute(ATTR)).toBe(false)
})
})