Files
apes-sidebar/tests/tab-bar-middle-click.spec.tsx
T
Menghuan1918 f17a1b30ac feat(ui): 自由窗口——tab 拖出侧边栏成为可移动/缩放/置顶的浮动窗口 (v0.16.0)
- 拖出:标签栏 tab 拖到主会话列(HTML5 DnD 检测 + 虚线提示浮层)或右键菜单
  「移动到自由窗口」(右/底面板共用 TabBar),tab 从 pane 移动进 SidebarState.floats
- 窗口(FreeWindow,渲染于 [data-dsh-panel-host] z-42):头部 pointer 拖动
  (rAF 直写 + 松手提交)、拖到 pane 高亮停靠(center 合并)、SE 角缩放、
  点击置顶、头部右键(回到侧边栏/关闭)、X = closeTab 走正常生命周期
- 服务语义:openTab dedupe/id 聚焦浮动 tab = 置顶窗口(不展开面板);
  closeTab/activateTab 兼顾 floats;agent 终端 reconcile 覆盖浮窗;
  features 单调新增 'floatWindows',版本 0.16.0
- 持久化:floats 随会话 localStorage,sanitizeState 宽容校验 + 几何钳制,
  uid 播种覆盖 float 前缀;i18n 三键 19 语言同步
- 关键修复(e2e 发现):portal 覆盖层(头部 Menu)的合成事件沿 React 树
  冒泡回拖拽表面,劫持菜单行点击——拖拽起点加 DOM 同源守卫(jsdom 不复现)
- e2e-mount.sh:缺省 tarball 改取 mtime 最新(旧字典序选择会把冒烟挂到
  过期产物);新增 tests/e2e/float-window.e2e.ts 双用例
2026-08-23 23:44:18 +08:00

193 lines
6.0 KiB
TypeScript

/**
* Tab-strip middle-click close tests. The press target is recorded on middle
* mousedown (preventDefaulted to disarm Chrome's middle-click autoscroll)
* and the close settles on the first middle mouseup OVER that same tab —
* release-position semantics matching VS Code (microsoft/vscode#101028) and
* user expectations for Chrome tabs (crbug/40679924): pressing on a tab and
* releasing elsewhere cancels. The browser dispatches auxclick to the common
* ancestor of the press/release targets when they differ (or suppresses it
* entirely), so settling on the recorded press target keeps release
* semantics without depending on auxclick delivery. Left-button paths
* (activate / drag) and wheel scrolling are untouched.
*/
// @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).
;(globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true
import { TabBar } from '../src/client/TabBar.tsx'
import type { SidebarTab } from '../src/client/state.ts'
function mountBar(): {
tab0: HTMLElement
tab1: HTMLElement
onClose: ReturnType<typeof vi.fn>
onActivate: ReturnType<typeof vi.fn>
unmount: () => void
} {
const container = document.createElement('div')
document.body.append(container)
const onClose = vi.fn()
const onActivate = vi.fn()
const tabs: SidebarTab[] = [
{ id: 't1', type: 'explorer', title: 'Explorer' },
{ id: 't2', type: 'git', title: 'Git' },
]
const root: Root = createRoot(container)
act(() => {
root.render(createElement(TabBar, {
paneId: 'pane:1',
tabs,
active: 't1',
onActivate,
onClose,
onNewTab: () => {},
newTabOptions: [],
onFloatTab: () => {},
onDropTab: () => {},
}))
})
const tabEls = [...container.querySelectorAll('[class*="tabList"] > [class*="tab"]')] as HTMLElement[]
expect(tabEls.length).toBe(2)
// noUncheckedIndexedAccess: the length assertion above is the guard, but
// the compiler still sees `HTMLElement | undefined` through indexing.
const tab0 = tabEls[0]!
const tab1 = tabEls[1]!
return {
tab0,
tab1,
onClose,
onActivate,
unmount: () => {
act(() => { root.unmount() })
container.remove()
},
}
}
/** Dispatch a native mouse event; returns the event (to read defaultPrevented). */
function mouse(target: EventTarget, type: string, button: number): MouseEvent {
const event = new MouseEvent(type, { bubbles: true, cancelable: true, button })
target.dispatchEvent(event)
return event
}
afterEach(() => {
document.body.innerHTML = ''
vi.restoreAllMocks()
})
describe('TabBar middle-click close', () => {
it('closes when the middle mouseup lands on the same tab', () => {
const { tab0, onClose, unmount } = mountBar()
try {
mouse(tab0, 'mousedown', 1)
mouse(tab0, 'mouseup', 1)
expect(onClose).toHaveBeenCalledWith('t1')
} finally {
unmount()
}
})
it('preventDefaults the middle mousedown (disarms browser autoscroll)', () => {
const { tab0, unmount } = mountBar()
try {
const event = mouse(tab0, 'mousedown', 1)
expect(event.defaultPrevented).toBe(true)
} finally {
unmount()
}
})
it('a release elsewhere (drag-away) cancels the close — one-shot', () => {
const { tab0, onClose, unmount } = mountBar()
try {
// Press on tab 0, release on unrelated element: no close.
mouse(tab0, 'mousedown', 1)
mouse(document.body, 'mouseup', 1)
expect(onClose).not.toHaveBeenCalled()
// A later middle mouseup over the tab (no new press) must NOT close.
mouse(tab0, 'mouseup', 1)
expect(onClose).not.toHaveBeenCalled()
} finally {
unmount()
}
})
it('a release over a DIFFERENT tab does not close', () => {
const { tab0, tab1, onClose, unmount } = mountBar()
try {
mouse(tab0, 'mousedown', 1)
mouse(tab1, 'mouseup', 1)
expect(onClose).not.toHaveBeenCalled()
} finally {
unmount()
}
})
it('middle mousedown on the close button closes (release over the tab)', () => {
const { tab0, onClose, unmount } = mountBar()
try {
const button = tab0.querySelector('button') as HTMLButtonElement
expect(button).not.toBeNull()
mouse(button, 'mousedown', 1)
mouse(button, 'mouseup', 1)
expect(onClose).toHaveBeenCalledWith('t1')
} finally {
unmount()
}
})
it('left mousedown does not close; left click activates', () => {
const { tab0, onClose, onActivate, unmount } = mountBar()
try {
mouse(tab0, 'mousedown', 0)
mouse(tab0, 'mouseup', 0)
expect(onClose).not.toHaveBeenCalled()
mouse(tab0, 'click', 0)
expect(onActivate).toHaveBeenCalledWith('t1')
} finally {
unmount()
}
})
it('a middle auxclick alone (no mousedown) does not close', () => {
// Pins the contract: the close settles on the mouseup of the recorded
// press, not on auxclick delivery — the browser redirects auxclick to
// the common ancestor (or suppresses it) when press/release targets
// differ, which was the failure mode.
const { tab0, onClose, unmount } = mountBar()
try {
mouse(tab0, 'auxclick', 1)
expect(onClose).not.toHaveBeenCalled()
} finally {
unmount()
}
})
it('a left mouseup after a middle press does not consume the press', () => {
const { tab0, onClose, unmount } = mountBar()
try {
mouse(tab0, 'mousedown', 1)
mouse(window, 'mouseup', 0)
expect(onClose).not.toHaveBeenCalled()
mouse(tab0, 'mouseup', 1)
expect(onClose).toHaveBeenCalledWith('t1')
} finally {
unmount()
}
})
it('stops closing after unmount', () => {
const { tab0, onClose, unmount } = mountBar()
mouse(tab0, 'mousedown', 1)
unmount()
mouse(tab0, 'mouseup', 1)
expect(onClose).not.toHaveBeenCalled()
})
})