Files
deepseek-harness/packages/client/ui-dockkit/tests/components.client.spec.tsx
T

1513 lines
74 KiB
TypeScript

// @vitest-environment jsdom
/**
* Component-level behaviour of the kit's React surface, driven by props alone:
* no cordis, no slot registry, no scaffold. These assert what a user sees and
* which settled intent each gesture reports — the layout maths itself is covered
* by the engine suites.
*
* jsdom lays nothing out, so the gesture specs hand the surface a layout: panes
* of one width side by side, each 600px tall with a 36px strip and 100px chips.
*/
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest'
import type { DockIntents } from '../src/contract/adapter.ts'
import type { PaneId, TabId } from '../src/contract/types.ts'
import { DockController } from '../src/engine/controller.ts'
import { applyOp } from '../src/engine/operations.ts'
import { FLOAT_DEFAULT_SIZE, FLOAT_MIN_SIZE } from '../src/engine/constraints.ts'
import { floatRectAt } from '../src/engine/geometry.ts'
import { DockSurface, type DockSurfaceProps } from '../src/components/DockSurface.tsx'
import type { TabMenuExtras } from '../src/contract/adapter.ts'
import { FloatLayer, type FloatLayerProps } from '../src/components/FloatLayer.tsx'
import { dockPaneIds, getPane } from '../src/engine/tree.ts'
import { TEST_LABELS, asPane, asTab, fileTab, seededController } from './fixtures.client.ts'
afterEach(() => {
cleanup()
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
/** Every intent as a spy, so a spec can assert exactly which one fired. */
function spyIntents(): DockIntents & Record<keyof DockIntents, ReturnType<typeof vi.fn>> {
return {
focusTab: vi.fn(),
focusPane: vi.fn(),
splitPane: vi.fn(),
addTab: vi.fn(),
closeTab: vi.fn(),
duplicateTab: vi.fn(),
floatTab: vi.fn(),
unfloatPane: vi.fn(),
placeTab: vi.fn(),
dropTab: vi.fn(),
moveFloat: vi.fn(),
resizeFloat: vi.fn(),
resizeSplit: vi.fn(),
} as DockIntents & Record<keyof DockIntents, ReturnType<typeof vi.fn>>
}
/** Render the docked surface over a controller's current layout. */
function renderSurface(
controller: DockController,
intents: DockIntents,
canSplit = true,
renderTabMenuItems?: TabMenuExtras,
options: Pick<DockSurfaceProps, 'dropZones' | 'minPaneFraction' | 'canCloseTab'> = {},
) {
const snapshot = controller.getSnapshot()
return render(
<DockSurface
{...options}
state={snapshot.state}
canSplit={canSplit}
intents={intents}
labels={TEST_LABELS}
renderTab={tab => <p data-testid="body">{tab.contentId}</p>}
{...renderTabMenuItems === undefined ? {} : { renderTabMenuItems }}
/>,
)
}
const box = (x: number, y: number, width: number, height: number): DOMRect =>
({ x, y, width, height, top: y, left: x, right: x + width, bottom: y + height, toJSON: () => ({}) })
/** Height every laid-out pane takes. */
const PANE_HEIGHT = 600
/** Width of one laid-out chip. */
const CHIP_WIDTH = 100
/**
* Lay the surface out: `panes` in visual order, each `paneWidth` wide, side by
* side from the origin. Every pane's strip is 36px tall inside a 1px border,
* its chips 100px wide from the strip's left edge, its fixed controls 104px.
* Dividers are 4px wide; the surface and every split container span every
* pane, except the split containers `splits` gives their own box.
*/
function layOut(panes: readonly PaneId[], paneWidth: number, splits: Readonly<Record<string, DOMRect>> = {}): void {
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
if (this.hasAttribute('data-dockkit-split')) {
return splits[this.getAttribute('data-dockkit-split') ?? ''] ?? box(0, 0, panes.length * paneWidth, PANE_HEIGHT)
}
if (this.hasAttribute('data-dockkit-surface')) return box(0, 0, panes.length * paneWidth, PANE_HEIGHT)
if (this.hasAttribute('data-dockkit-divider')) return box(0, 0, 4, PANE_HEIGHT)
const pane = this.closest<HTMLElement>('[data-dockkit-pane]')
const index = pane === null ? -1 : panes.indexOf(asPane(pane.dataset.dockkitPane ?? ''))
if (pane === null || index < 0) return box(0, 0, 0, 0)
const left = index * paneWidth
if (this === pane) return box(left, 0, paneWidth, PANE_HEIGHT)
const chips = [...pane.querySelectorAll('[data-dockkit-tab]')]
if (this.hasAttribute('data-dockkit-strip')) return box(left + 1, 1, paneWidth - 2, 36)
if (this.hasAttribute('data-dockkit-strip-tabs')) return box(left + 1, 1, chips.length * CHIP_WIDTH, 34)
if (this.hasAttribute('data-dockkit-strip-fill')) {
return box(left + 1 + chips.length * CHIP_WIDTH + 32, 1, Math.max(0, paneWidth - 2 - chips.length * CHIP_WIDTH - 104), 34)
}
if (this.hasAttribute('data-dockkit-tab')) return box(left + 1 + chips.indexOf(this) * CHIP_WIDTH, 1, CHIP_WIDTH, 34)
return box(0, 0, 0, 0)
})
}
/** Press `element` at a point and move to another, releasing there unless told not to. */
function drag(element: Element, from: readonly [number, number], to: readonly [number, number], release = true): void {
fireEvent.pointerDown(element, { clientX: from[0], clientY: from[1], pointerId: 7, button: 0 })
fireEvent.pointerMove(window, { pointerId: 7, clientX: to[0], clientY: to[1] })
if (release) fireEvent.pointerUp(window, { pointerId: 7, clientX: to[0], clientY: to[1] })
}
/** Two seeded docked panes, the first also holding a content tab, with the layout state on a spied intent set. */
function twoPanes(
paneWidth = 420,
canSplit = true,
options: Pick<DockSurfaceProps, 'dropZones' | 'minPaneFraction'> = {},
): {
intents: ReturnType<typeof spyIntents>
first: PaneId
second: PaneId
seedTabId: TabId
fileTabId: TabId
chip: (tabId: TabId) => HTMLElement
unmount: () => void
} {
const controller = seededController()
controller.setExpanded(true)
controller.splitPane()
const [first, second] = dockPaneIds(controller.getSnapshot().state)
if (first === undefined || second === undefined) throw new Error('expected two docked panes')
const fileTabId = controller.openContent({
contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'file', paneId: first,
})
const seedTabId = getPane(controller.getSnapshot().state, first).tabs[0]
if (seedTabId === undefined) throw new Error('expected the seeded tab')
layOut([first, second], paneWidth)
const intents = spyIntents()
const { unmount } = renderSurface(controller, intents, canSplit, undefined, options)
const chip = (tabId: TabId): HTMLElement => {
const element = document.querySelector<HTMLElement>(`[data-dockkit-tab="${tabId}"]`)
if (element === null) throw new Error(`no chip for ${tabId}`)
return element
}
return { intents, first, second, seedTabId, fileTabId, chip, unmount }
}
/** The chip the layout puts second in the first pane: pressed at its centre. */
const FILE_CHIP: readonly [number, number] = [151, 18]
/** Which gesture intents fired, in order. */
function gestureIntents(intents: ReturnType<typeof spyIntents>): string[] {
return (['placeTab', 'dropTab', 'floatTab', 'resizeSplit'] as const).filter(name => intents[name].mock.calls.length > 0)
}
describe('DockSurface', () => {
it('renders each pane with its tabs and the active tab body', () => {
const controller = seededController()
controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'file' })
renderSurface(controller, spyIntents())
// The chip's text is its title alone: the close control is an icon.
expect(screen.getAllByRole('tab').map(tab => tab.textContent)).toEqual(['Start', 'a.txt'])
expect(screen.getByTestId('body').textContent).toBe('dsh-resource://file/session/s/a.txt')
expect(screen.getByRole('tab', { name: /a\.txt/u }).getAttribute('aria-selected')).toBe('true')
})
it('shows the empty-pane label when a pane holds nothing', () => {
const controller = new DockController()
renderSurface(controller, spyIntents())
expect(screen.getByText(TEST_LABELS.emptyPane)).toBeDefined()
})
it('reports one focus intent when a tab is clicked, not when it is pressed, and none for the pane', () => {
const controller = seededController()
controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'file' })
const intents = spyIntents()
renderSurface(controller, intents)
const tab = screen.getByRole('tab', { name: /Start/u })
fireEvent.pointerDown(tab)
expect(intents.focusTab).not.toHaveBeenCalled()
fireEvent.click(tab)
const seeded = Object.values(controller.getSnapshot().state.tabs)[0]
expect(intents.focusTab).toHaveBeenCalledTimes(1)
expect(intents.focusTab).toHaveBeenCalledWith(seeded?.id)
// The tab's own operation decides the active pane; the pane records nothing of its own.
expect(intents.focusPane).not.toHaveBeenCalled()
})
it('records nothing for a click on the active pane\'s selected chip, but selects that chip in another pane', () => {
const controller = seededController()
controller.setExpanded(true)
controller.splitPane()
const state = controller.getSnapshot().state
const [first, second] = dockPaneIds(state)
if (first === undefined || second === undefined) throw new Error('expected two docked panes')
expect(state.activePaneId).toBe(second)
const intents = spyIntents()
renderSurface(controller, intents)
const selectedChip = (paneId: PaneId): Element => {
const chip = document.querySelector(`[data-dockkit-pane="${paneId}"] [data-dockkit-tab][aria-selected="true"]`)
if (chip === null) throw new Error(`expected a selected chip in ${paneId}`)
return chip
}
fireEvent.click(selectedChip(second))
expect(intents.focusTab).not.toHaveBeenCalled()
fireEvent.click(selectedChip(first))
expect(intents.focusTab).toHaveBeenCalledWith(getPane(state, first).activeTabId)
expect(intents.focusPane).not.toHaveBeenCalled()
})
it('focuses a pane when its body or strip is clicked, unless it is the active pane already', () => {
const controller = seededController()
controller.setExpanded(true)
controller.splitPane()
const state = controller.getSnapshot().state
const [first, second] = dockPaneIds(state)
if (first === undefined || second === undefined) throw new Error('expected two docked panes')
expect(state.activePaneId).toBe(second)
const intents = spyIntents()
renderSurface(controller, intents)
const body = (paneId: PaneId): Element => {
const element = document.querySelector(`[data-dockkit-pane="${paneId}"] [data-testid="body"]`)
if (element === null) throw new Error(`expected a body in ${paneId}`)
return element
}
// The active pane: nothing would change, so nothing is recorded.
fireEvent.click(body(second))
fireEvent.click(document.querySelector(`[data-dockkit-strip="${second}"]`) ?? body(second))
expect(intents.focusPane).not.toHaveBeenCalled()
// The other pane: its body and its strip's blank area both focus it.
fireEvent.click(body(first))
fireEvent.click(document.querySelector(`[data-dockkit-strip="${first}"]`) ?? body(first))
expect(intents.focusPane).toHaveBeenCalledTimes(2)
expect(intents.focusPane).toHaveBeenCalledWith(first)
expect(intents.focusTab).not.toHaveBeenCalled()
})
it('reports a split intent from the pane control, and disables it when the budget is spent', () => {
const controller = seededController()
const intents = spyIntents()
const { unmount } = renderSurface(controller, intents)
fireEvent.click(screen.getByRole('button', { name: TEST_LABELS.splitPane }))
expect(intents.splitPane).toHaveBeenCalledWith(controller.getSnapshot().state.rootId)
expect(intents.focusPane).not.toHaveBeenCalled()
unmount()
renderSurface(controller, intents, false)
const disabled = screen.getByRole('button', { name: TEST_LABELS.splitPane })
expect(disabled.hasAttribute('disabled')).toBe(true)
expect(disabled.getAttribute('title')).toBe(TEST_LABELS.splitPaneDisabled)
expect(disabled.getAttribute('data-dockkit-split-blocked')).toBe('budget')
})
it('names the enabled split control through the shared tooltip, not a native title', () => {
vi.useFakeTimers()
try {
renderSurface(seededController(), spyIntents())
const button = screen.getByRole('button', { name: TEST_LABELS.splitPane })
expect(button.hasAttribute('title')).toBe(false)
fireEvent.mouseEnter(button)
act(() => { vi.advanceTimersByTime(500) })
expect(screen.getByRole('tooltip').textContent).toBe(TEST_LABELS.splitPane)
fireEvent.mouseLeave(button)
expect(screen.queryByRole('tooltip')).toBeNull()
} finally {
vi.useRealTimers()
}
})
it('hides blocked split controls when opted in and restores them when capacity returns', () => {
const controller = seededController()
controller.splitPane()
const state = controller.getSnapshot().state
layOut(dockPaneIds(state), 520)
const intents = spyIntents()
const props: DockSurfaceProps = {
state, canSplit: false, hideSplitWhenBlocked: true, intents, labels: TEST_LABELS, renderTab: tab => <p>{tab.title}</p>,
}
const view = render(<DockSurface {...props} />)
expect(screen.queryByRole('button', { name: TEST_LABELS.splitPane })).toBeNull()
expect(view.container.querySelectorAll('[data-dockkit-split-button]')).toHaveLength(0)
expect(view.container.querySelectorAll('[data-dockkit-pane]')).toHaveLength(2)
view.rerender(<DockSurface {...props} canSplit />)
const buttons = screen.getAllByRole('button', { name: TEST_LABELS.splitPane })
expect(buttons).toHaveLength(2)
expect(buttons.every(button => !button.hasAttribute('disabled'))).toBe(true)
fireEvent.click(buttons[0]!)
expect(intents.splitPane).toHaveBeenCalledExactlyOnceWith(dockPaneIds(state)[0])
})
// jsdom lays nothing out, so the room rule reads the rectangles this spec
// hands it: two panes, one wide enough for two halves and one not. The
// strip's fixed part is 104px in both (the chrome pane's controls), the chip
// minimum falls back to the stylesheet's 100px.
it.each([false, true])('disables or hides the width-blocked split control with hideSplitWhenBlocked=%s', (hideSplitWhenBlocked) => {
const controller = seededController()
controller.setExpanded(true)
controller.splitPane()
const snapshot = controller.getSnapshot()
const [wide, narrow] = dockPaneIds(snapshot.state)
if (wide === undefined || narrow === undefined) throw new Error('expected two docked panes')
const widths: Record<string, number> = { [wide]: 520, [narrow]: 208 }
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
const paneWidth = widths[this.closest<HTMLElement>('[data-dockkit-pane]')?.dataset.dockkitPane ?? ''] ?? 0
if (this.hasAttribute('data-dockkit-pane')) return box(0, 0, paneWidth, 600)
if (this.hasAttribute('data-dockkit-strip')) return box(0, 0, paneWidth - 2, 36)
if (this.hasAttribute('data-dockkit-strip-tabs')) return box(0, 0, 60, 24)
if (this.hasAttribute('data-dockkit-strip-fill')) return box(0, 0, Math.max(0, paneWidth - 2 - 60 - 104), 24)
return box(0, 0, 0, 0)
})
render(
<DockSurface
state={snapshot.state}
canSplit
hideSplitWhenBlocked={hideSplitWhenBlocked}
intents={controller}
labels={TEST_LABELS}
renderTab={tab => <p>{tab.contentId}</p>}
/>,
)
const wideButton = document.querySelector(`[data-dockkit-split-button="${wide}"]`)
const narrowButton = document.querySelector(`[data-dockkit-split-button="${narrow}"]`)
expect(wideButton?.hasAttribute('disabled')).toBe(false)
expect(wideButton?.hasAttribute('title')).toBe(false)
if (hideSplitWhenBlocked) {
expect(narrowButton).toBeNull()
} else {
expect(narrowButton?.hasAttribute('disabled')).toBe(true)
expect(narrowButton?.getAttribute('title')).toBe(TEST_LABELS.splitPaneNarrow)
expect(narrowButton?.getAttribute('data-dockkit-split-blocked')).toBe('width')
}
})
it('reports the room readings through onRoom, and re-reads them when the surface resizes', () => {
class FakeResizeObserver implements ResizeObserver {
static latest: FakeResizeObserver | undefined
readonly observe = vi.fn()
readonly unobserve = vi.fn()
readonly disconnect = vi.fn()
constructor(private readonly callback: ResizeObserverCallback) {
FakeResizeObserver.latest = this
}
/** What the platform does when the observed element's size changes. */
fire(): void {
this.callback([], this)
}
}
vi.stubGlobal('ResizeObserver', FakeResizeObserver)
const controller = seededController()
controller.setExpanded(true)
controller.splitPane()
const snapshot = controller.getSnapshot()
const panes = dockPaneIds(snapshot.state)
let paneWidth = 520
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
if (this.hasAttribute('data-dockkit-pane')) return box(0, 0, paneWidth, 600)
if (this.hasAttribute('data-dockkit-strip')) return box(0, 0, paneWidth - 2, 36)
if (this.hasAttribute('data-dockkit-strip-tabs')) return box(0, 0, 60, 24)
if (this.hasAttribute('data-dockkit-strip-fill')) return box(0, 0, Math.max(0, paneWidth - 2 - 60 - 104), 24)
return box(0, 0, 0, 0)
})
const onRoom = vi.fn()
const { unmount } = render(
<DockSurface
state={snapshot.state}
canSplit
intents={controller}
labels={TEST_LABELS}
renderTab={tab => <p>{tab.contentId}</p>}
onRoom={onRoom}
/>,
)
const observer = FakeResizeObserver.latest
if (observer === undefined) throw new Error('expected the surface to observe its own size')
expect(observer.observe).toHaveBeenCalledWith(document.querySelector('[data-dockkit-surface]'))
expect(onRoom).toHaveBeenLastCalledWith(new Map(panes.map(id => [id, { row: true, column: true }])))
const readings = onRoom.mock.calls.length
// The same reading again renders nothing new.
act(() => { observer.fire() })
expect(onRoom).toHaveBeenCalledTimes(readings)
// The column narrowed: every pane loses the room for a row split.
paneWidth = 208
act(() => { observer.fire() })
expect(onRoom).toHaveBeenLastCalledWith(new Map(panes.map(id => [id, { row: false, column: true }])))
unmount()
expect(observer.disconnect).toHaveBeenCalledTimes(1)
})
// The room reading must not flip with the hidden control's own footprint:
// hiding the width-blocked control widens the fill by the control plus the
// strip's gap, and a reading that counted the control would fit again, show
// it, and re-render forever (React's update-depth limit, which crashed the
// surface). The rule leaves the footprint out, so the fill the fake hands
// back here depends on whether the control is in the DOM — exactly the
// feedback the fix breaks.
it('reads the same room whether hideSplitWhenBlocked has hidden the split control or not', () => {
class FakeResizeObserver implements ResizeObserver {
static latest: FakeResizeObserver | undefined
readonly observe = vi.fn()
readonly unobserve = vi.fn()
readonly disconnect = vi.fn()
constructor(private readonly callback: ResizeObserverCallback) {
FakeResizeObserver.latest = this
}
/** What the platform does when the observed element's size changes. */
fire(): void {
this.callback([], this)
}
}
vi.stubGlobal('ResizeObserver', FakeResizeObserver)
const controller = seededController()
controller.setExpanded(true)
const snapshot = controller.getSnapshot()
// In the band where only the control's 28px footprint decides the fit:
// half 188px against 76px of other fixed controls plus the 100px chip.
let paneWidth = 380
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
const pane = this.closest<HTMLElement>('[data-dockkit-pane]')
if (this.hasAttribute('data-dockkit-pane')) return box(0, 0, paneWidth, 600)
if (this.hasAttribute('data-dockkit-strip')) return box(0, 0, paneWidth - 2, 36)
if (this.hasAttribute('data-dockkit-strip-tabs')) return box(0, 0, 60, 24)
if (this.hasAttribute('data-dockkit-split-button')) return box(0, 0, 28, 28)
if (this.hasAttribute('data-dockkit-strip-fill')) {
const fixed = pane?.querySelector('[data-dockkit-split-button]') === null ? 76 : 104
return box(0, 0, Math.max(0, paneWidth - 2 - 60 - fixed), 24)
}
return box(0, 0, 0, 0)
})
render(
<DockSurface
state={snapshot.state}
canSplit
hideSplitWhenBlocked
intents={controller}
labels={TEST_LABELS}
renderTab={tab => <p>{tab.contentId}</p>}
/>,
)
// Settled with the control shown: the discounted reading fits either way.
expect(document.querySelector('[data-dockkit-split-button]')).not.toBeNull()
// Narrowed under the discounted minimum: hidden, and the reading without
// the control settles hidden.
const observer = FakeResizeObserver.latest
if (observer === undefined) throw new Error('expected the surface to observe its own size')
paneWidth = 300
act(() => { observer.fire() })
expect(document.querySelector('[data-dockkit-split-button]')).toBeNull()
})
it('names the chip box\'s hidden sides in data-dockkit-strip-scroll as it scrolls', () => {
let scrollLeft = 0
const descriptors = ['scrollLeft', 'scrollWidth', 'clientWidth'].map(name =>
[name, Object.getOwnPropertyDescriptor(Element.prototype, name)] as const)
const strip = (element: Element): boolean => element.hasAttribute('data-dockkit-strip-tabs')
Object.defineProperty(Element.prototype, 'scrollLeft', { configurable: true, get(this: Element) { return strip(this) ? scrollLeft : 0 } })
Object.defineProperty(Element.prototype, 'scrollWidth', { configurable: true, get(this: Element) { return strip(this) ? 400 : 0 } })
Object.defineProperty(Element.prototype, 'clientWidth', { configurable: true, get(this: Element) { return strip(this) ? 200 : 0 } })
try {
const controller = seededController()
renderSurface(controller, spyIntents())
const box = document.querySelector<HTMLElement>('[data-dockkit-strip-tabs]')
if (box === null) throw new Error('expected the chip box')
expect(box.getAttribute('data-dockkit-strip-scroll')).toBe('end')
scrollLeft = 100
fireEvent.scroll(box)
expect(box.getAttribute('data-dockkit-strip-scroll')).toBe('start end')
scrollLeft = 200
fireEvent.scroll(box)
expect(box.getAttribute('data-dockkit-strip-scroll')).toBe('start')
} finally {
for (const [name, descriptor] of descriptors) {
if (descriptor === undefined) Reflect.deleteProperty(Element.prototype, name)
else Object.defineProperty(Element.prototype, name, descriptor)
}
}
})
it('scrolls the chip box to the active chip when it lies past either edge, clearing the fade band', () => {
let scrollLeft = 0
const descriptor = Object.getOwnPropertyDescriptor(Element.prototype, 'scrollLeft')
Object.defineProperty(Element.prototype, 'scrollLeft', {
configurable: true,
get() { return scrollLeft },
set(value: number) { scrollLeft = value },
})
// The box spans 0..200; the seeded chip sits at 40..130 and the opened one where `openedAt` says.
let openedTab: TabId | undefined
let openedAt = 250
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
if (this.hasAttribute('data-dockkit-strip-tabs')) return box(0, 0, 200, 28)
if (this.getAttribute('data-dockkit-tab') === openedTab) return box(openedAt, 0, 90, 28)
if (this.hasAttribute('data-dockkit-tab')) return box(40, 0, 90, 28)
return box(0, 0, 0, 0)
})
const surface = (controller: DockController) => (
<DockSurface
state={controller.getSnapshot().state}
canSplit
intents={controller}
labels={TEST_LABELS}
renderTab={tab => <p>{tab.contentId}</p>}
/>
)
try {
const controller = seededController()
const seededTab = getPane(controller.getSnapshot().state, controller.getSnapshot().state.activePaneId).activeTabId
if (seededTab === undefined) throw new Error('expected the seeded tab')
const { rerender } = render(surface(controller))
// The seeded chip is in view: nothing moves.
expect(scrollLeft).toBe(0)
openedTab = controller.openContent({ contentId: 'dsh-resource://file/session/s/b.txt', title: 'b.txt', kind: 'file' })
rerender(surface(controller))
// Past the right edge by 140, plus the 24px fade.
expect(scrollLeft).toBe(164)
// Selected again once it lies 30 past the left edge: back by 30 plus the fade.
openedAt = -30
controller.focusTab(seededTab)
rerender(surface(controller))
controller.focusTab(openedTab)
rerender(surface(controller))
expect(scrollLeft).toBe(164 - 30 - 24)
} finally {
if (descriptor === undefined) Reflect.deleteProperty(Element.prototype, 'scrollLeft')
else Object.defineProperty(Element.prototype, 'scrollLeft', descriptor)
}
})
it('marks a chip title clipped while its text is wider than its box, re-reading on resize', () => {
class FakeResizeObserver implements ResizeObserver {
static readonly all: FakeResizeObserver[] = []
readonly observe = vi.fn()
readonly unobserve = vi.fn()
readonly disconnect = vi.fn()
constructor(private readonly callback: ResizeObserverCallback) {
FakeResizeObserver.all.push(this)
}
fire(): void {
this.callback([], this)
}
}
vi.stubGlobal('ResizeObserver', FakeResizeObserver)
let clientWidth = 200
const descriptors = ['scrollWidth', 'clientWidth'].map(name =>
[name, Object.getOwnPropertyDescriptor(Element.prototype, name)] as const)
const title = (element: Element): boolean => element.hasAttribute('data-dockkit-tab-title')
Object.defineProperty(Element.prototype, 'scrollWidth', { configurable: true, get(this: Element) { return title(this) ? 120 : 0 } })
Object.defineProperty(Element.prototype, 'clientWidth', { configurable: true, get(this: Element) { return title(this) ? clientWidth : 0 } })
try {
const controller = seededController()
const { unmount } = renderSurface(controller, spyIntents())
const span = document.querySelector<HTMLElement>('[data-dockkit-tab-title]')
if (span === null) throw new Error('expected a chip title')
const observer = FakeResizeObserver.all.find(candidate => candidate.observe.mock.calls.some(([target]) => target === span))
if (observer === undefined) throw new Error('expected the title to observe its own size')
expect(span.hasAttribute('data-dockkit-tab-clipped')).toBe(false)
// The chip narrowed under the text.
clientWidth = 80
act(() => { observer.fire() })
expect(span.hasAttribute('data-dockkit-tab-clipped')).toBe(true)
clientWidth = 200
act(() => { observer.fire() })
expect(span.hasAttribute('data-dockkit-tab-clipped')).toBe(false)
unmount()
expect(observer.disconnect).toHaveBeenCalledTimes(1)
} finally {
for (const [name, descriptor] of descriptors) {
if (descriptor === undefined) Reflect.deleteProperty(Element.prototype, name)
else Object.defineProperty(Element.prototype, name, descriptor)
}
vi.unstubAllGlobals()
}
})
it('asks for the seeded tab from the strip\'s add control, naming the pane and nothing else', () => {
const controller = seededController()
const intents = spyIntents()
renderSurface(controller, intents)
fireEvent.click(screen.getByRole('button', { name: TEST_LABELS.addTab }))
expect(intents.addTab).toHaveBeenCalledWith(controller.getSnapshot().state.rootId)
expect(intents.focusPane).not.toHaveBeenCalled()
})
it('draws the add control only where the embedder\'s canAddTab allows, pane by pane', () => {
const controller = seededController()
controller.setExpanded(true)
controller.splitPane()
const snapshot = controller.getSnapshot()
const [first, second] = dockPaneIds(snapshot.state)
if (first === undefined || second === undefined) throw new Error('expected two docked panes')
render(
<DockSurface
state={snapshot.state}
canSplit
canAddTab={paneId => paneId === second}
intents={controller}
labels={TEST_LABELS}
renderTab={tab => <p>{tab.contentId}</p>}
chrome={<button type="button">chrome</button>}
/>,
)
expect(document.querySelector(`[data-dockkit-pane="${first}"] [data-dockkit-add-tab]`)).toBeNull()
expect(document.querySelector(`[data-dockkit-pane="${second}"] [data-dockkit-add-tab]`)).not.toBeNull()
// The strip's end controls stay: only the add control is missing.
const strip = document.querySelector(`[data-dockkit-pane="${first}"] [data-dockkit-strip]`)
expect(strip?.querySelector('[data-dockkit-split-button]')).not.toBeNull()
expect(strip?.querySelector('[data-dockkit-strip-tabs]')).not.toBeNull()
})
it('withholds a tab\'s close control and menu close item where the embedder\'s canCloseTab denies, tab by tab', () => {
const controller = seededController()
controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'file' })
const snapshot = controller.getSnapshot()
const seedTabId = getPane(snapshot.state, snapshot.state.activePaneId).tabs[0]
if (seedTabId === undefined) throw new Error('expected seeded tab')
render(
<DockSurface
state={snapshot.state}
canSplit
canCloseTab={tabId => tabId !== seedTabId}
intents={controller}
labels={TEST_LABELS}
renderTab={tab => <p>{tab.contentId}</p>}
renderTabMenuItems={(_, dismiss) => <button type="button" role="menuitem" onClick={dismiss}>embedder item</button>}
/>,
)
const [seedChip, fileChip] = screen.getAllByRole('tab')
if (seedChip === undefined || fileChip === undefined) throw new Error('expected two chips')
expect(seedChip.querySelector('[data-dockkit-tab-close]')).toBeNull()
expect(fileChip.querySelector('[data-dockkit-tab-close]')).not.toBeNull()
// The menu still opens on the withheld chip: the embedder's items remain reachable.
fireEvent.contextMenu(seedChip)
expect(screen.getAllByRole('menuitem').map(item => item.textContent)).toEqual(['embedder item'])
fireEvent.pointerDown(document.body)
fireEvent.contextMenu(fileChip)
expect(screen.getAllByRole('menuitem').map(item => item.textContent)).toEqual([TEST_LABELS.closeTab, 'embedder item'])
})
it('draws a lone unclosable chip quiet, and shows no menu on it when the embedder renders no item', () => {
const controller = seededController()
render(
<DockSurface
state={controller.getSnapshot().state}
canSplit
canCloseTab={() => false}
intents={controller}
labels={TEST_LABELS}
renderTab={tab => <p>{tab.contentId}</p>}
renderTabMenuItems={() => undefined}
/>,
)
const [chip] = screen.getAllByRole('tab')
if (chip === undefined) throw new Error('expected the seeded chip')
// The lone unclosable chip draws quiet: no capsule, no hover fill.
expect(chip.getAttribute('data-dockkit-tab-quiet')).toBe('true')
// The menu would hold nothing, so it dismisses itself before painting.
fireEvent.contextMenu(chip)
expect(document.querySelector('[data-dockkit-tab-menu]')).toBeNull()
})
it('lets the embedder render a chip\'s title, and shows the record\'s text when it does not', () => {
const controller = seededController()
controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'file' })
const snapshot = controller.getSnapshot()
render(
<DockSurface
state={snapshot.state}
canSplit
intents={controller}
labels={TEST_LABELS}
renderTab={tab => <p>{tab.contentId}</p>}
renderTabTitle={tab => tab.kind === 'file' ? <em data-testid="rich-title">{tab.title.toUpperCase()}</em> : undefined}
/>,
)
const titles = [...document.querySelectorAll('[data-dockkit-tab-title]')].map(el => el.textContent)
expect(titles).toEqual(['Start', 'A.TXT'])
expect(screen.getByTestId('rich-title').tagName).toBe('EM')
})
it('closes a tab from the chip\'s own control, without starting a drag', () => {
const controller = seededController()
const intents = spyIntents()
renderSurface(controller, intents)
const close = screen.getByRole('button', { name: TEST_LABELS.closeTab })
// A press on the nested control must not reach the chip's gesture handler;
// if it did, the pointer would be captured and this click would never land.
fireEvent.pointerDown(close)
fireEvent.click(close)
expect(intents.closeTab).toHaveBeenCalledTimes(1)
expect(intents.focusTab).not.toHaveBeenCalled()
})
it('opens the context menu on a secondary press, offering close and closing after acting', () => {
const controller = seededController()
const intents = spyIntents()
renderSurface(controller, intents)
expect(screen.queryByRole('menu')).toBeNull()
fireEvent.contextMenu(screen.getByRole('tab'))
expect(screen.getByRole('menu')).toBeDefined()
fireEvent.click(screen.getByRole('menuitem', { name: TEST_LABELS.closeTab }))
expect(intents.closeTab).toHaveBeenCalledTimes(1)
expect(screen.queryByRole('menu')).toBeNull()
})
it('offers close only for tabs the embedder allows', () => {
const controller = seededController()
const allowed = controller.openContent({ contentId: 'resource:a', title: 'A', kind: 'test' })
const intents = spyIntents()
renderSurface(controller, intents, true, undefined, { canCloseTab: tabId => tabId === allowed })
expect(screen.getByRole('button', { name: TEST_LABELS.closeTab }).getAttribute('data-dockkit-tab-close')).toBe(allowed)
fireEvent.contextMenu(screen.getByRole('tab', { name: 'Start' }))
expect(screen.queryByRole('menu')).toBeNull()
fireEvent.contextMenu(screen.getByRole('tab', { name: /A/u }))
fireEvent.click(screen.getByRole('menuitem', { name: TEST_LABELS.closeTab }))
expect(intents.closeTab).toHaveBeenCalledExactlyOnceWith(allowed)
})
it.each([
['absent', undefined],
['null', null],
['conditional', false],
['empty array', []],
['empty text', ''],
])('hides close and creates no popup when extras are %s', (_, extras) => {
const controller = seededController()
const intents = spyIntents()
renderSurface(controller, intents, true, extras === undefined ? undefined : () => extras, { canCloseTab: () => false })
expect(screen.queryByRole('button', { name: TEST_LABELS.closeTab })).toBeNull()
fireEvent.contextMenu(screen.getByRole('tab'))
expect(screen.queryByRole('menuitem', { name: TEST_LABELS.closeTab })).toBeNull()
expect(screen.queryByRole('menu')).toBeNull()
expect(intents.closeTab).not.toHaveBeenCalled()
})
it('updates chip and open-menu close controls when eligibility props change', () => {
const controller = seededController()
const intents = spyIntents()
const props: DockSurfaceProps = {
state: controller.getSnapshot().state,
canSplit: true,
intents,
labels: TEST_LABELS,
renderTab: () => null,
canCloseTab: () => false,
}
const { rerender } = render(<DockSurface {...props} />)
expect(screen.queryByRole('button', { name: TEST_LABELS.closeTab })).toBeNull()
rerender(<DockSurface {...props} canCloseTab={() => true} />)
expect(screen.getByRole('button', { name: TEST_LABELS.closeTab })).toBeDefined()
fireEvent.contextMenu(screen.getByRole('tab'))
expect(screen.getByRole('menuitem', { name: TEST_LABELS.closeTab })).toBeDefined()
rerender(<DockSurface {...props} />)
expect(screen.queryByRole('button', { name: TEST_LABELS.closeTab })).toBeNull()
expect(screen.queryByRole('menu')).toBeNull()
rerender(<DockSurface {...props} canCloseTab={() => true} />)
fireEvent.click(screen.getByRole('menuitem', { name: TEST_LABELS.closeTab }))
expect(intents.closeTab).toHaveBeenCalledTimes(1)
expect(screen.queryByRole('menu')).toBeNull()
fireEvent.click(screen.getByRole('button', { name: TEST_LABELS.closeTab }))
expect(intents.closeTab).toHaveBeenCalledTimes(2)
})
it.each([false, true])('hides the popup when an extras component renders no content inside a wrapper: %s', (wrapped) => {
const EmptyExtras = () => null
renderSurface(seededController(), spyIntents(), true,
() => wrapped ? <div style={{ display: 'contents' }}><EmptyExtras /></div> : <EmptyExtras />,
{ canCloseTab: () => false })
fireEvent.contextMenu(screen.getByRole('tab'))
const menu = document.querySelector<HTMLElement>('[data-dockkit-tab-menu]')
if (menu === null) throw new Error('expected the extras component menu')
// Vitest stubs CSS Modules; bind the real stylesheet's menu selector to its generated class.
const style = document.createElement('style')
style.textContent = readFileSync(resolve(import.meta.dirname, '../src/components/dockkit.module.css'), 'utf8')
.replaceAll(/\.menu(?=[:\s{])/g, `.${menu.className}`)
document.head.append(style)
onTestFinished(() => { style.remove() })
expect(menu.querySelectorAll('[role^="menuitem"]')).toHaveLength(0)
expect(getComputedStyle(menu).display).toBe('none')
expect(screen.queryByRole('menu')).toBeNull()
})
it('toggles the menu closed on a second secondary press, and dismisses it on a press anywhere else', () => {
const controller = seededController()
renderSurface(controller, spyIntents())
const tab = screen.getByRole('tab')
fireEvent.contextMenu(tab)
fireEvent.contextMenu(tab)
expect(screen.queryByRole('menu')).toBeNull()
fireEvent.contextMenu(tab)
expect(screen.getByRole('menu')).toBeDefined()
fireEvent.pointerDown(document.body)
expect(screen.queryByRole('menu')).toBeNull()
// A press with no element target, dispatched to the window itself, counts as outside.
fireEvent.contextMenu(tab)
fireEvent.pointerDown(window)
expect(screen.queryByRole('menu')).toBeNull()
})
it('hangs the menu below the chip\'s left edge, or from its right edge when it would run off the viewport', () => {
const controller = seededController()
let left = 10
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
return this.hasAttribute('data-dockkit-tab') ? box(left, 4, 60, 26) : box(0, 0, 0, 0)
})
const { unmount } = renderSurface(controller, spyIntents())
fireEvent.contextMenu(screen.getByRole('tab'))
expect(screen.getByRole('menu').style.top).toBe('34px')
expect(screen.getByRole('menu').style.left).toBe('10px')
unmount()
left = window.innerWidth + 100
renderSurface(controller, spyIntents())
fireEvent.contextMenu(screen.getByRole('tab'))
expect(screen.getByRole('menu').style.left).toBe(`${left + 60}px`)
})
it('offers no copy or float item: those gestures are the embedder\'s API and the drag', () => {
const controller = seededController()
renderSurface(controller, spyIntents())
fireEvent.contextMenu(screen.getByRole('tab'))
expect(screen.getAllByRole('menuitem').map(item => item.textContent)).toEqual([TEST_LABELS.closeTab])
expect(document.querySelector('[data-dockkit-tab-more]')).toBeNull()
})
it.each([true, false])('keeps embedder menu items and their tab and dismiss when close is allowed: %s', (canClose) => {
const controller = seededController()
const acted = vi.fn<(contentId: string) => void>()
const extras: TabMenuExtras = (tab, dismiss) => (
<button
type="button"
role="menuitem"
data-testid="extra"
onClick={() => { acted(tab.contentId); dismiss() }}
>
embedder item
</button>
)
const intents = spyIntents()
renderSurface(controller, intents, true, extras, { canCloseTab: () => canClose })
fireEvent.contextMenu(screen.getByRole('tab'))
// Order is contract: the kit's own item stays in the same place in every
// menu, so an embedder item cannot displace it.
expect(screen.getAllByRole('menuitem').map(item => item.textContent)).toEqual(
canClose ? [TEST_LABELS.closeTab, 'embedder item'] : ['embedder item'],
)
expect(screen.queryByRole('button', { name: TEST_LABELS.closeTab }) !== null).toBe(canClose)
fireEvent.click(screen.getByTestId('extra'))
expect(acted).toHaveBeenCalledWith('seed:start')
expect(intents.closeTab).not.toHaveBeenCalled()
expect(screen.queryByRole('menu')).toBeNull()
})
it('keeps a menu press from starting a tab drag', () => {
const controller = seededController()
const intents = spyIntents()
renderSurface(controller, intents)
fireEvent.contextMenu(screen.getByRole('tab'))
const item = screen.getByRole('menuitem', { name: TEST_LABELS.closeTab })
// A press inside the menu must not reach the chip's gesture handler; if it
// did, the pointer would be captured and this click would never land.
fireEvent.pointerDown(item)
fireEvent.click(item)
expect(intents.closeTab).toHaveBeenCalledTimes(1)
})
})
describe('tab drags', () => {
it('previews a caret at the slot under the pointer and reports that slot on release', () => {
const { intents, first, fileTabId, chip } = twoPanes()
// Left of the first chip's midpoint: slot 0.
drag(chip(fileTabId), FILE_CHIP, [30, 18], false)
expect(document.querySelector('[data-dockkit-caret]')?.getAttribute('data-dockkit-caret')).toBe('0')
// Past both chips: the slot after the last one, drawn at the strip's end.
fireEvent.pointerMove(window, { pointerId: 7, clientX: 300, clientY: 18 })
expect(document.querySelector('[data-dockkit-caret]')?.getAttribute('data-dockkit-caret')).toBe('2')
fireEvent.pointerUp(window, { pointerId: 7, clientX: 300, clientY: 18 })
// The slot counts the dragged chip itself; the planner turns it into a reorder.
expect(intents.placeTab).toHaveBeenCalledWith(fileTabId, first, 2)
expect(gestureIntents(intents)).toEqual(['placeTab'])
expect(document.querySelector('[data-dockkit-caret]')).toBeNull()
})
it('reports a slot in another pane\'s strip as a placement there', () => {
const { intents, second, fileTabId, chip } = twoPanes()
drag(chip(fileTabId), FILE_CHIP, [420 + 30, 18])
expect(intents.placeTab).toHaveBeenCalledWith(fileTabId, second, 0)
})
it('shows the dock hint on the pane under the pointer and reports the zone on release', () => {
const { intents, second, fileTabId, chip } = twoPanes(520)
drag(chip(fileTabId), FILE_CHIP, [520 + 260, 300], false)
const hint = document.querySelector(`[data-dockkit-pane="${second}"] [data-dockkit-dock-zone]`)
expect(hint?.getAttribute('data-dockkit-dock-zone')).toBe('center')
fireEvent.pointerMove(window, { pointerId: 7, clientX: 520 + 510, clientY: 300 })
expect(document.querySelector('[data-dockkit-dock-zone]')?.getAttribute('data-dockkit-dock-zone')).toBe('right')
fireEvent.pointerUp(window, { pointerId: 7, clientX: 520 + 510, clientY: 300 })
expect(intents.dropTab).toHaveBeenCalledWith(fileTabId, second, 'right')
expect(document.querySelector('[data-dockkit-dock-zone]')).toBeNull()
})
it('offers no edge zone once the pane budget is spent: a release there moves nothing', () => {
const { intents, fileTabId, chip } = twoPanes(420, false)
drag(chip(fileTabId), FILE_CHIP, [420 + 410, 300], false)
expect(document.querySelector('[data-dockkit-dock-zone]')).toBeNull()
fireEvent.pointerUp(window, { pointerId: 7, clientX: 420 + 410, clientY: 300 })
expect(gestureIntents(intents)).toEqual([])
})
// 208px panes: room for a column split (600px tall) but not for a row split.
it('offers an edge zone only on the axis the pane has room to split on', () => {
const { intents, second, fileTabId, chip } = twoPanes(208)
drag(chip(fileTabId), FILE_CHIP, [208 + 200, 300], false)
expect(document.querySelector('[data-dockkit-dock-zone]')).toBeNull()
fireEvent.pointerMove(window, { pointerId: 7, clientX: 208 + 104, clientY: 500 })
expect(document.querySelector('[data-dockkit-dock-zone]')?.getAttribute('data-dockkit-dock-zone')).toBe('bottom')
fireEvent.pointerMove(window, { pointerId: 7, clientX: 208 + 104, clientY: 100 })
expect(document.querySelector('[data-dockkit-dock-zone]')?.getAttribute('data-dockkit-dock-zone')).toBe('top')
fireEvent.pointerUp(window, { pointerId: 7, clientX: 208 + 104, clientY: 100 })
expect(intents.dropTab).toHaveBeenCalledWith(fileTabId, second, 'top')
})
it('floats a tab released clear of the surface, with the panel under the drop point', () => {
const { intents, fileTabId, chip } = twoPanes()
drag(chip(fileTabId), FILE_CHIP, [2000, 900])
expect(intents.floatTab).toHaveBeenCalledWith(fileTabId, floatRectAt(2000, 900, FLOAT_DEFAULT_SIZE))
expect(gestureIntents(intents)).toEqual(['floatTab'])
})
it('treats a press that never travels the threshold as no drag at all', () => {
const { intents, fileTabId, chip } = twoPanes()
drag(chip(fileTabId), FILE_CHIP, [FILE_CHIP[0] + 2, FILE_CHIP[1] + 1], false)
expect(document.querySelector('[data-dockkit-caret]')).toBeNull()
fireEvent.pointerUp(window, { pointerId: 7, clientX: 2000, clientY: 900 })
expect(gestureIntents(intents)).toEqual([])
})
it('ignores a secondary press: that is the menu, never a drag', () => {
const { intents, fileTabId, chip } = twoPanes()
fireEvent.pointerDown(chip(fileTabId), { clientX: FILE_CHIP[0], clientY: FILE_CHIP[1], pointerId: 7, button: 2 })
fireEvent.pointerMove(window, { pointerId: 7, clientX: 2000, clientY: 900 })
fireEvent.pointerUp(window, { pointerId: 7, clientX: 2000, clientY: 900 })
expect(gestureIntents(intents)).toEqual([])
})
it('drops the preview and reports nothing when the platform cancels the pointer', () => {
const { intents, fileTabId, chip } = twoPanes()
drag(chip(fileTabId), FILE_CHIP, [30, 18], false)
expect(document.querySelector('[data-dockkit-caret]')).not.toBeNull()
fireEvent.pointerCancel(window, { pointerId: 7 })
expect(document.querySelector('[data-dockkit-caret]')).toBeNull()
fireEvent.pointerUp(window, { pointerId: 7, clientX: 30, clientY: 18 })
expect(gestureIntents(intents)).toEqual([])
})
it('lets a new press take over from a gesture still in flight', () => {
const { intents, first, seedTabId, fileTabId, chip } = twoPanes()
drag(chip(fileTabId), FILE_CHIP, [30, 18], false)
expect(document.querySelector('[data-dockkit-caret]')).not.toBeNull()
// The second press ends the first gesture: its preview clears and its release never reports.
fireEvent.pointerDown(chip(seedTabId), { clientX: 51, clientY: 18, pointerId: 8, button: 0 })
expect(document.querySelector('[data-dockkit-caret]')).toBeNull()
fireEvent.pointerMove(window, { pointerId: 8, clientX: 300, clientY: 18 })
fireEvent.pointerUp(window, { pointerId: 8, clientX: 300, clientY: 18 })
expect(intents.placeTab).toHaveBeenCalledTimes(1)
expect(intents.placeTab).toHaveBeenCalledWith(seedTabId, first, 2)
})
it('stops following the pointer when unmounted mid-gesture', () => {
const { intents, fileTabId, chip, unmount } = twoPanes()
drag(chip(fileTabId), FILE_CHIP, [30, 18], false)
unmount()
fireEvent.pointerMove(window, { pointerId: 7, clientX: 300, clientY: 18 })
fireEvent.pointerUp(window, { pointerId: 7, clientX: 300, clientY: 18 })
expect(gestureIntents(intents)).toEqual([])
})
it('follows only its own pointer: another pointer neither moves, releases, nor cancels the gesture', () => {
const { intents, first, fileTabId, chip } = twoPanes()
fireEvent.pointerDown(chip(fileTabId), { clientX: FILE_CHIP[0], clientY: FILE_CHIP[1], pointerId: 7, button: 0 })
fireEvent.pointerMove(window, { pointerId: 9, clientX: 30, clientY: 18 })
expect(document.querySelector('[data-dockkit-caret]')).toBeNull()
fireEvent.pointerUp(window, { pointerId: 9, clientX: 30, clientY: 18 })
fireEvent.pointerCancel(window, { pointerId: 9 })
expect(gestureIntents(intents)).toEqual([])
// The gesture is still alive for its own pointer.
fireEvent.pointerMove(window, { pointerId: 7, clientX: 30, clientY: 18 })
expect(document.querySelector('[data-dockkit-caret]')?.getAttribute('data-dockkit-caret')).toBe('0')
fireEvent.pointerUp(window, { pointerId: 7, clientX: 30, clientY: 18 })
expect(intents.placeTab).toHaveBeenCalledWith(fileTabId, first, 0)
expect(intents.placeTab).toHaveBeenCalledTimes(1)
})
it('captures the pointer on the pressed chip where the platform offers capture', () => {
const capture = vi.fn<(pointerId: number) => void>()
Object.defineProperty(HTMLElement.prototype, 'setPointerCapture', { value: capture, configurable: true, writable: true })
try {
const { fileTabId, chip } = twoPanes()
fireEvent.pointerDown(chip(fileTabId), { clientX: FILE_CHIP[0], clientY: FILE_CHIP[1], pointerId: 7, button: 0 })
expect(capture).toHaveBeenCalledWith(7)
} finally {
Reflect.deleteProperty(HTMLElement.prototype, 'setPointerCapture')
}
})
})
describe('horizontal workbench drops', () => {
it.each([
{ x: 550, y: 300, zone: 'left' },
{ x: 1010, y: 590, zone: 'right' },
] as const)('offers both halves and targets $zone at ($x, $y)', ({ x, y, zone }) => {
const { intents, second, fileTabId, chip } = twoPanes(520, true, { dropZones: 'horizontal' })
drag(chip(fileTabId), FILE_CHIP, [x, y], false)
const hints = document.querySelectorAll('[data-dockkit-dock-zone]')
expect([...hints].map(hint => hint.getAttribute('data-dockkit-dock-zone'))).toEqual(['left', 'right'])
expect(document.querySelector('[data-dockkit-drop-active]')?.getAttribute('data-dockkit-dock-zone')).toBe(zone)
fireEvent.pointerUp(window, { pointerId: 7, clientX: x, clientY: y })
expect(intents.dropTab).toHaveBeenCalledExactlyOnceWith(fileTabId, second, zone)
})
it('uses the entire other pane for moving once the split budget is spent', () => {
const { intents, second, fileTabId, chip } = twoPanes(420, false, { dropZones: 'horizontal' })
drag(chip(fileTabId), FILE_CHIP, [425, 590])
expect(intents.dropTab).toHaveBeenCalledExactlyOnceWith(fileTabId, second, 'center')
})
it('does not offer a new split when a pane cannot fit two halves', () => {
const { intents, second, fileTabId, chip } = twoPanes(300, true, { dropZones: 'horizontal' })
drag(chip(fileTabId), FILE_CHIP, [450, 300], false)
expect(document.querySelectorAll('[data-dockkit-dock-zone]')).toHaveLength(1)
expect(document.querySelector('[data-dockkit-dock-zone]')?.getAttribute('data-dockkit-dock-zone')).toBe('center')
fireEvent.pointerUp(window, { pointerId: 7, clientX: 450, clientY: 300 })
expect(intents.dropTab).toHaveBeenCalledExactlyOnceWith(fileTabId, second, 'center')
})
it('clamps the live divider preview and committed sizes to the requested minimum', () => {
const { intents } = twoPanes(420, false, { minPaneFraction: 0.2 })
const divider = document.querySelector('[data-dockkit-divider]')
if (divider === null) throw new Error('expected a divider')
drag(divider, [420, 300], [0, 300], false)
const cells = document.querySelectorAll<HTMLElement>('[data-dockkit-cell]')
expect(Number(cells[0]?.style.flexGrow)).toBeCloseTo(0.2)
expect(Number(cells[1]?.style.flexGrow)).toBeCloseTo(0.8)
fireEvent.pointerUp(window, { pointerId: 7, clientX: 0, clientY: 300 })
expect(intents.resizeSplit.mock.calls[0]?.[1]).toEqual([0.2, 0.8])
})
})
describe('keyboard tabs', () => {
/** One pane holding the seed and two files, the last opened selected, with the chips by title. */
function threeChips(): { intents: ReturnType<typeof spyIntents>; chip: (title: string) => HTMLElement; ids: Record<string, TabId> } {
const controller = seededController()
const a = controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'file' })
const b = controller.openContent({ contentId: 'dsh-resource://file/session/s/b.txt', title: 'b.txt', kind: 'file' })
const seed = getPane(controller.getSnapshot().state, controller.getSnapshot().state.rootId).tabs[0]
if (seed === undefined) throw new Error('expected the seeded tab')
const intents = spyIntents()
renderSurface(controller, intents)
const chip = (title: string): HTMLElement => screen.getByRole('tab', { name: new RegExp(title.replace('.', '\\.'), 'u') })
return { intents, chip, ids: { Start: seed, 'a.txt': a, 'b.txt': b } }
}
it('puts only the selected chip in the tab order', () => {
const { chip } = threeChips()
expect(chip('b.txt').tabIndex).toBe(0)
expect(chip('a.txt').tabIndex).toBe(-1)
expect(chip('Start').tabIndex).toBe(-1)
})
it('moves focus with Left and Right, wrapping, and with Home and End, without selecting', () => {
const { intents, chip } = threeChips()
chip('b.txt').focus()
fireEvent.keyDown(chip('b.txt'), { key: 'ArrowRight' })
expect(document.activeElement).toBe(chip('Start'))
fireEvent.keyDown(chip('Start'), { key: 'ArrowLeft' })
expect(document.activeElement).toBe(chip('b.txt'))
fireEvent.keyDown(chip('b.txt'), { key: 'ArrowLeft' })
expect(document.activeElement).toBe(chip('a.txt'))
fireEvent.keyDown(chip('a.txt'), { key: 'Home' })
expect(document.activeElement).toBe(chip('Start'))
fireEvent.keyDown(chip('Start'), { key: 'End' })
expect(document.activeElement).toBe(chip('b.txt'))
expect(intents.focusTab).not.toHaveBeenCalled()
})
it('selects the focused chip with Enter or Space, once each, and ignores other keys', () => {
const { intents, chip, ids } = threeChips()
chip('b.txt').focus()
fireEvent.keyDown(chip('b.txt'), { key: 'ArrowLeft' })
fireEvent.keyDown(chip('a.txt'), { key: 'Enter' })
expect(intents.focusTab).toHaveBeenCalledWith(ids['a.txt'])
fireEvent.keyDown(chip('a.txt'), { key: 'Home' })
fireEvent.keyDown(chip('Start'), { key: ' ' })
expect(intents.focusTab).toHaveBeenCalledWith(ids.Start)
fireEvent.keyDown(chip('Start'), { key: 'a' })
fireEvent.keyDown(chip('Start'), { key: 'Escape' })
expect(intents.focusTab).toHaveBeenCalledTimes(2)
})
it('records nothing for Enter on the active pane\'s selected chip, and leaves keys on the close control alone', () => {
const { intents, chip } = threeChips()
fireEvent.keyDown(chip('b.txt'), { key: 'Enter' })
fireEvent.keyDown(chip('b.txt'), { key: ' ' })
const close = chip('a.txt').querySelector('[data-dockkit-tab-close]')
if (close === null) throw new Error('expected the close control')
fireEvent.keyDown(close, { key: 'Enter' })
fireEvent.keyDown(close, { key: 'ArrowLeft' })
expect(intents.focusTab).not.toHaveBeenCalled()
expect(intents.closeTab).not.toHaveBeenCalled()
})
})
describe('divider drags', () => {
/** A row split whose second pane is itself split into a column: the column split's id and a spied intent set. */
function withColumn(columnBox: DOMRect): { intents: ReturnType<typeof spyIntents>; columnId: string } {
const controller = seededController()
controller.setExpanded(true)
controller.splitPane()
const [first, second] = dockPaneIds(controller.getSnapshot().state)
if (first === undefined || second === undefined) throw new Error('expected two docked panes')
const opened = controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'file', paneId: second })
controller.dropTab(opened, second, 'bottom')
const state = controller.getSnapshot().state
const root = state.nodes[state.rootId]
const columnId = root?.kind === 'split' ? root.children[1] : undefined
if (columnId === undefined || state.nodes[columnId]?.kind !== 'split') throw new Error('expected a column split')
layOut(dockPaneIds(state), 420, { [columnId]: columnBox })
const intents = spyIntents()
renderSurface(controller, intents)
return { intents, columnId }
}
it('moves a column split\'s divider along the vertical axis', () => {
const { intents, columnId } = withColumn(box(420, 0, 420, 600))
const divider = document.querySelector(`[data-dockkit-divider="${columnId}:0"]`)
if (divider === null) throw new Error('expected the column divider')
drag(divider, [630, 300], [630, 360])
const [reportedSplit, sizes] = intents.resizeSplit.mock.calls[0] as [string, readonly number[]]
expect(reportedSplit).toBe(columnId)
expect(sizes[0]).toBeCloseTo(0.6)
expect(sizes[1]).toBeCloseTo(0.4)
})
it('reports nothing for a release that left the fractions where they were', () => {
const { intents } = twoPanes()
const divider = document.querySelector('[data-dockkit-divider]')
if (divider === null) throw new Error('expected a divider between the two panes')
// A click on the divider.
drag(divider, [420, 300], [420, 300])
// A drag returned to where it began.
drag(divider, [420, 300], [504, 300], false)
fireEvent.pointerMove(window, { pointerId: 7, clientX: 420, clientY: 300 })
fireEvent.pointerUp(window, { pointerId: 7, clientX: 420, clientY: 300 })
expect(intents.resizeSplit).not.toHaveBeenCalled()
// A real move still reports, once.
drag(divider, [420, 300], [504, 300])
expect(intents.resizeSplit).toHaveBeenCalledTimes(1)
})
it('reports nothing along a split that has no measured extent', () => {
const { intents, columnId } = withColumn(box(420, 0, 420, 0))
const divider = document.querySelector(`[data-dockkit-divider="${columnId}:0"]`)
if (divider === null) throw new Error('expected the column divider')
drag(divider, [630, 300], [630, 360])
expect(intents.resizeSplit).not.toHaveBeenCalled()
})
it('previews the fractions while the divider moves and reports the net result once', () => {
const { intents, first, second } = twoPanes()
const divider = document.querySelector('[data-dockkit-divider]')
if (divider === null) throw new Error('expected a divider between the two panes')
const splitId = divider.getAttribute('data-dockkit-divider')?.split(':')[0]
// The split spans 840px, so 84px of travel is a tenth of it.
drag(divider, [420, 300], [504, 300], false)
const cell = (index: number): number =>
Number(document.querySelector<HTMLElement>(`[data-dockkit-cell="${splitId}:${index}"]`)?.style.flexGrow)
expect(cell(0)).toBeCloseTo(0.6)
expect(cell(1)).toBeCloseTo(0.4)
fireEvent.pointerUp(window, { pointerId: 7, clientX: 504, clientY: 300 })
expect(intents.resizeSplit).toHaveBeenCalledTimes(1)
const [reportedSplit, sizes] = intents.resizeSplit.mock.calls[0] as [string, readonly number[]]
expect(reportedSplit).toBe(splitId)
expect(sizes[0]).toBeCloseTo(0.6)
expect(sizes[1]).toBeCloseTo(0.4)
// The recorded fractions come back through the state; the preview is gone.
expect(cell(0)).toBeCloseTo(0.5)
expect(intents.placeTab).not.toHaveBeenCalled()
expect([first, second]).toHaveLength(2)
})
})
describe('FloatLayer', () => {
/** Two floating panels, `lower` under `upper`, with `upper` active. */
function twoFloats(): {
intents: ReturnType<typeof spyIntents>
lower: PaneId
upper: PaneId
panel: (paneId: PaneId) => HTMLElement
part: (attribute: string, paneId: PaneId) => Element
} {
const controller = seededController()
const first = controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'file' })
const second = controller.openContent({ contentId: 'dsh-resource://file/session/s/b.txt', title: 'b.txt', kind: 'file' })
const lower = controller.floatTab(first, { x: 100, y: 80, width: 300, height: 200 })
const upper = controller.floatTab(second, { x: 500, y: 80, width: 300, height: 200 })
const intents = spyIntents()
render(<FloatLayer state={controller.getSnapshot().state} intents={intents} labels={TEST_LABELS} renderTab={() => null} />)
const part = (attribute: string, paneId: PaneId): Element => {
const element = document.querySelector(`[${attribute}="${paneId}"]`)
if (element === null) throw new Error(`expected ${attribute} of ${paneId}`)
return element
}
const panel = (paneId: PaneId): HTMLElement => {
const element = document.querySelector<HTMLElement>(`[data-dockkit-float="${paneId}"]`)
if (element === null) throw new Error(`expected the panel ${paneId}`)
return element
}
return { intents, lower, upper, panel, part }
}
/** One floating panel over a spied intent set. */
function floating(canCloseTab?: FloatLayerProps['canCloseTab']): {
intents: ReturnType<typeof spyIntents>
paneId: PaneId
tabId: TabId
panel: HTMLElement
} {
const controller = seededController()
const tabId = controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'file' })
const paneId = controller.floatTab(tabId, { x: 100, y: 80, width: 300, height: 200 })
const intents = spyIntents()
render(
<FloatLayer
state={controller.getSnapshot().state}
intents={intents}
labels={TEST_LABELS}
renderTab={tab => <p data-testid="float-body">{tab.title}</p>}
{...canCloseTab === undefined ? {} : { canCloseTab }}
/>,
)
const panel = document.querySelector<HTMLElement>(`[data-dockkit-float="${paneId}"]`)
if (panel === null) throw new Error('expected a floating panel')
return { intents, paneId, tabId, panel }
}
it('draws one panel per floating pane, without a tab strip', () => {
const { intents } = floating()
expect(screen.getByTestId('float-body').textContent).toBe('a.txt')
expect(screen.queryByRole('tablist')).toBeNull()
fireEvent.click(screen.getByRole('button', { name: TEST_LABELS.dockFloat }))
expect(intents.unfloatPane).toHaveBeenCalledTimes(1)
})
it('closes a floating panel through its own control', () => {
const { intents, tabId } = floating()
fireEvent.click(screen.getByRole('button', { name: TEST_LABELS.closeFloat }))
expect(intents.closeTab).toHaveBeenCalledWith(tabId)
})
it('hides a floating tab close control when the embedder disallows it', () => {
const canCloseTab = vi.fn(() => false)
const { intents, tabId } = floating(canCloseTab)
expect(canCloseTab).toHaveBeenCalledWith(tabId)
expect(screen.queryByRole('button', { name: TEST_LABELS.closeFloat })).toBeNull()
expect(screen.getByTestId('float-body').textContent).toBe('a.txt')
fireEvent.click(screen.getByRole('button', { name: TEST_LABELS.dockFloat }))
expect(intents.unfloatPane).toHaveBeenCalledTimes(1)
expect(intents.closeTab).not.toHaveBeenCalled()
})
it('raises a panel on a press on its body, but not from a press on its controls, grip, or corner', () => {
const { intents, lower, panel, part } = twoFloats()
fireEvent.pointerDown(panel(lower))
expect(intents.focusPane).toHaveBeenCalledWith(lower)
fireEvent.pointerDown(part('data-dockkit-float-dock', lower))
fireEvent.pointerDown(part('data-dockkit-float-close', lower))
fireEvent.pointerDown(part('data-dockkit-float-grip', lower), { clientX: 150, clientY: 90, pointerId: 7, button: 0 })
fireEvent.pointerDown(part('data-dockkit-float-resize', lower), { clientX: 400, clientY: 280, pointerId: 8, button: 0 })
expect(intents.focusPane).toHaveBeenCalledTimes(1)
})
it('records nothing for a press on the panel that is active and on top already', () => {
const { intents, upper, panel, part } = twoFloats()
fireEvent.pointerDown(panel(upper))
// A grip or corner press released in place is a click on the same panel.
drag(part('data-dockkit-float-grip', upper), [550, 90], [550, 90])
drag(part('data-dockkit-float-resize', upper), [800, 280], [800, 280])
expect(intents.focusPane).not.toHaveBeenCalled()
expect(intents.moveFloat).not.toHaveBeenCalled()
expect(intents.resizeFloat).not.toHaveBeenCalled()
})
it('raises the active panel when another has been drawn over it', () => {
const controller = seededController()
const first = controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'file' })
const second = controller.openContent({ contentId: 'dsh-resource://file/session/s/b.txt', title: 'b.txt', kind: 'file' })
const lower = controller.floatTab(first, { x: 100, y: 80, width: 300, height: 200 })
const upper = controller.floatTab(second, { x: 500, y: 80, width: 300, height: 200 })
// Focus on the lower panel while the upper stays on top: only a recorded
// focus snapshot puts the layout there, as an undo does.
const buried = applyOp(controller.getSnapshot().state, {
type: 'restoreFocus', activePaneId: lower, floats: [lower, upper], paneActiveTabs: {},
}).state
const intents = spyIntents()
render(<FloatLayer state={buried} intents={intents} labels={TEST_LABELS} renderTab={() => null} />)
const panel = document.querySelector(`[data-dockkit-float="${lower}"]`)
if (panel === null) throw new Error('expected the lower panel')
fireEvent.pointerDown(panel)
expect(intents.focusPane).toHaveBeenCalledWith(lower)
})
it('moves a panel by its grip as one intent, previewing the position and the panel on top', () => {
const controller = seededController()
const first = controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'file' })
const second = controller.openContent({ contentId: 'dsh-resource://file/session/s/b.txt', title: 'b.txt', kind: 'file' })
const lower = controller.floatTab(first, { x: 100, y: 80, width: 300, height: 200 })
controller.floatTab(second, { x: 500, y: 80, width: 300, height: 200 })
const intents = spyIntents()
render(<FloatLayer state={controller.getSnapshot().state} intents={intents} labels={TEST_LABELS} renderTab={() => null} />)
const panel = document.querySelector<HTMLElement>(`[data-dockkit-float="${lower}"]`)
const grip = document.querySelector(`[data-dockkit-float-grip="${lower}"]`)
if (panel === null || grip === null) throw new Error('expected the lower panel')
expect(panel.style.zIndex).toBe('1')
drag(grip, [150, 90], [180, 110], false)
expect(panel.style.left).toBe('130px')
expect(panel.style.top).toBe('100px')
// Mid-gesture the panel draws on top, as the move it records will leave it.
expect(panel.style.zIndex).toBe('3')
fireEvent.pointerUp(window, { pointerId: 7, clientX: 180, clientY: 110 })
expect(intents.moveFloat).toHaveBeenCalledWith(lower, 130, 100)
expect(intents.moveFloat).toHaveBeenCalledTimes(1)
expect(intents.focusPane).not.toHaveBeenCalled()
// The recorded rectangle and depth are drawn again once the preview clears.
expect(panel.style.left).toBe('100px')
expect(panel.style.zIndex).toBe('1')
})
it('treats a press on the grip or corner released where it began as a click that raises the panel', () => {
const { intents, lower, part } = twoFloats()
drag(part('data-dockkit-float-grip', lower), [150, 90], [150, 90])
drag(part('data-dockkit-float-resize', lower), [400, 280], [400, 280])
expect(intents.focusPane).toHaveBeenCalledTimes(2)
expect(intents.focusPane).toHaveBeenCalledWith(lower)
expect(intents.moveFloat).not.toHaveBeenCalled()
expect(intents.resizeFloat).not.toHaveBeenCalled()
})
it('resizes from the corner as one intent, holding the minimum size', () => {
const { intents, paneId, panel } = floating()
const corner = document.querySelector(`[data-dockkit-float-resize="${paneId}"]`)
if (corner === null) throw new Error('expected a resize corner')
drag(corner, [400, 280], [440, 310], false)
expect(panel.style.width).toBe('340px')
expect(panel.style.height).toBe('230px')
fireEvent.pointerMove(window, { pointerId: 7, clientX: -1000, clientY: -1000 })
expect(panel.style.width).toBe(`${FLOAT_MIN_SIZE.width}px`)
fireEvent.pointerUp(window, { pointerId: 7, clientX: -1000, clientY: -1000 })
expect(intents.resizeFloat).toHaveBeenCalledWith(paneId, { x: 100, y: 80, ...FLOAT_MIN_SIZE })
expect(intents.resizeFloat).toHaveBeenCalledTimes(1)
expect(intents.focusPane).not.toHaveBeenCalled()
})
it('stacks panels in the model z order', () => {
const controller = seededController()
const first = controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'file' })
const second = controller.openContent({ contentId: 'dsh-resource://file/session/s/b.txt', title: 'b.txt', kind: 'file' })
controller.floatTab(first)
controller.floatTab(second)
render(
<FloatLayer
state={controller.getSnapshot().state}
intents={spyIntents()}
labels={TEST_LABELS}
renderTab={() => null}
/>,
)
const zIndexes = screen.getAllByRole('banner').map(header => header.parentElement?.style.zIndex)
expect(zIndexes).toEqual(['1', '2'])
})
})
describe('surface chrome', () => {
it('renders the embedder\'s controls once, at the end of the top-right pane\'s strip', () => {
const controller = seededController()
controller.setExpanded(true)
// A row split puts the new pane to the right; a bottom drop under it puts a
// pane below that. The top-right pane is the right column's upper one.
controller.splitPane()
const right = controller.getSnapshot().state
const root = right.nodes[right.rootId]
const rightPaneId = root?.kind === 'split' ? root.children[1] : undefined
if (rightPaneId === undefined) throw new Error('expected a row split')
const seeded = controller.getSnapshot().state.nodes[rightPaneId]
if (seeded === undefined || seeded.kind !== 'pane' || seeded.tabs[0] === undefined) throw new Error('expected a seeded pane')
const opened = controller.openContent({ contentId: 'dsh-resource://file/session/s/b.txt', title: 'b.txt', kind: 'file', paneId: seeded.id })
controller.dropTab(opened, seeded.id, 'bottom')
const snapshot = controller.getSnapshot()
render(
<DockSurface
state={snapshot.state}
canSplit
intents={controller}
labels={TEST_LABELS}
renderTab={tab => <p>{tab.contentId}</p>}
chrome={<button type="button">chrome</button>}
/>,
)
const hosts = screen.getAllByRole('button', { name: 'chrome' })
expect(hosts).toHaveLength(1)
const pane = hosts[0]?.closest('[data-dockkit-pane]')
// The upper of the two right-hand panes, not the lower one the drop created.
expect(pane?.getAttribute('data-dockkit-pane')).toBe(rightPaneId)
// At the strip's end: after the split control.
const strip = pane?.querySelector('[data-dockkit-strip]')
const children = [...(strip?.children ?? [])]
expect(children.at(-1)?.hasAttribute('data-dockkit-strip-chrome')).toBe(true)
})
it('keeps a click on the chrome from focusing the pane: the embedder\'s controls report their own intents', () => {
const controller = seededController()
const intents = spyIntents()
render(
<DockSurface
state={controller.getSnapshot().state}
canSplit
intents={intents}
labels={TEST_LABELS}
renderTab={tab => <p>{tab.contentId}</p>}
chrome={<button type="button">chrome</button>}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'chrome' }))
expect(intents.focusPane).not.toHaveBeenCalled()
})
it('renders no chrome host when the embedder supplies none', () => {
const controller = seededController()
renderSurface(controller, spyIntents())
expect(document.querySelector('[data-dockkit-strip-chrome]')).toBeNull()
})
// Layout itself is the stylesheet's; what the DOM promises is that every
// chip and drop caret sits inside the strip's one shrinking box, and that the
// controls after it are the strip's own children, in this order.
it('keeps the chips in their own box ahead of the add, split, and chrome controls', () => {
const controller = seededController()
controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'file' })
const snapshot = controller.getSnapshot()
render(
<DockSurface
state={snapshot.state}
canSplit
intents={controller}
labels={TEST_LABELS}
renderTab={tab => <p>{tab.contentId}</p>}
chrome={<button type="button">chrome</button>}
/>,
)
const strip = document.querySelector('[data-dockkit-strip]')
const role = (child: Element): string => {
if (child.hasAttribute('data-dockkit-strip-tabs')) return 'tabs'
if (child.hasAttribute('data-dockkit-add-tab')) return 'add'
if (child.hasAttribute('data-dockkit-split-button')) return 'split'
if (child.hasAttribute('data-dockkit-strip-chrome')) return 'chrome'
return 'fill'
}
expect([...(strip?.children ?? [])].map(role)).toEqual(['tabs', 'add', 'fill', 'split', 'chrome'])
expect(strip?.querySelectorAll('[data-dockkit-strip-tabs] [data-dockkit-tab]')).toHaveLength(2)
expect(strip?.querySelectorAll(':scope > [data-dockkit-tab]')).toHaveLength(0)
})
})
describe('a controller satisfies the intent contract', () => {
it('accepts a DockController wherever DockIntents is required', () => {
const controller = seededController()
const intents: DockIntents = controller
renderSurface(controller, intents)
fireEvent.click(screen.getByRole('button', { name: TEST_LABELS.splitPane }))
// One click, one entry: the split seeded its new pane, and that seating is
// what decides the active pane — the control's click reports nothing else.
expect(controller.ops.map(op => op.type)).toEqual(['split', 'openTab'])
})
})
describe('fileTab', () => {
it('builds a content tab the kit treats as opaque', () => {
expect(fileTab(asTab('t1'), 'dsh-resource://file/session/s/x', 'x').kind).toBe('file')
})
})