Files
deepseek-harness/packages/client/ui-layout/tests/app-frame.spec.tsx
imccyu 7e6f0128b5 fix(gui): keep sidebar controls when collapsed
A closed sidebar previously resolved to a zero-width grid track, clipping
the only toggle and the settings entry with no visible recovery; the closed
preference persisted across reloads, locking the sidebar shut.

- columns.ts maps the closed preference (width 0) to a fixed 60px
  SIDEBAR_COLLAPSED rail through every step of the concession solve;
  closed details still resolve to zero width.
- AppFrame derives data-sidebar-collapsed and the sidebar slot's collapsed
  owner prop from the persisted preference instead of the resolved track
  width, and drops the resize handle while collapsed.
- SidebarRoot reads the owner collapsed prop; the expanded-only body is a
  separate component that unmounts while collapsed (dropping its sessions
  subscription), leaving the expand toggle and Settings in the rail.
- The keyless web smoke gains the ui-sidebar bundle (six real bundles) and
  pins the 60px rail collapse/expand round through the assembled client.
2026-07-23 10:51:57 +08:00

276 lines
13 KiB
TypeScript

// @vitest-environment jsdom
/**
* AppFrame interaction spec under the four-share props form: real layout
* store instance (createLayoutStore().create() — the test-sanctioned engine
* path), a recording renderSlot stub, and a render-prop SessionProvider stub
* (the real one is framework-wired to the renderer host; its own behavior is
* web-react's spec territory). Drag sequences (pointer capture + rAF flush),
* concession response to viewport change, and details staying mounted at
* zero width are the preserved behavior assertions. jsdom has no layout
* engine, so the frame width comes from a mocked getBoundingClientRect and
* resizes are driven through the ResizeObserver stub.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, render } from '@testing-library/react'
import { useSyncExternalStore } from 'react'
import { AppFrame } from '@deepseek-ai/dsh-client-ui-layout/src/client/AppFrame.tsx'
import type { AppFrameProps } from '@deepseek-ai/dsh-client-ui-layout/src/client/AppFrame.tsx'
import { SIDEBAR_COLLAPSED } from '@deepseek-ai/dsh-client-ui-layout/src/client/columns.ts'
import { createLayoutStore } from '@deepseek-ai/dsh-client-ui-layout/src/client/stores.ts'
// Session-mode switch for the SessionProvider stub prop.
const sessionMode = { current: true }
// Render-prop contract stub fed through the standard seat prop (the renderer
// injects the real one in production): session mode runs children(id), empty
// mode runs the empty branch — the frame must work against exactly this
// shape. Typed as the seat's own component type so the branded sessionId
// parameter stays contract-checked.
const SessionProviderStub: AppFrameProps['SessionProvider'] = ({ children, empty }) =>
sessionMode.current ? <>{children('s-test' as Parameters<typeof children>[0])}</> : <>{empty?.() ?? null}</>
/** Observer stub: captures the callback so tests can fire resizes manually. */
let fireResize: (() => void) | null = null
class ResizeObserverStub {
#cb: ResizeObserverCallback
constructor(cb: ResizeObserverCallback) { this.#cb = cb }
observe(): void { fireResize = () => { this.#cb([], this as unknown as ResizeObserver) } }
unobserve(): void {}
disconnect(): void { fireResize = null }
}
let frameWidth = 1920
/** Minimal selector hook over an engine instance (the engine carries no hook since the store migration; the renderer binds in production, the spec binds here). */
function hookOf<T>(inst: { subscribe: (fn: () => void) => () => void; getSnapshot: () => T }) {
return <S,>(sel: (s: T) => S): S => sel(useSyncExternalStore(inst.subscribe, inst.getSnapshot))
}
function mountFrame() {
window.innerWidth = frameWidth // first-render viewport source before the observer fires
const instance = createLayoutStore().create()
instance.actions.openDetails() // seed: sidebar at default 300, details open at default 360
const slotCalls: { key: string; props: unknown }[] = []
const renderSlot = ((key: string, owner: object) => {
slotCalls.push({ key, props: owner })
if (key === 'sidebar') return <div data-testid="sidebar-content" />
if (key === 'conversation') return <div data-testid="center-content" />
if (key === 'details') return <div data-testid="details-content" />
return <div data-testid="empty-content" />
}) as AppFrameProps['renderSlot']
const useSessions = ((sel: (s: unknown) => unknown) => sel({ ids: [], byId: {} })) as never
const utils = render(
<AppFrame
useStore={hookOf(instance) as never}
actions={instance.actions}
renderSlot={renderSlot}
useSessions={useSessions}
SessionProvider={SessionProviderStub}
/>,
)
const frame = utils.container.firstElementChild as HTMLElement
return { instance, frame, slotCalls, ...utils }
}
function tracks(frame: HTMLElement): number[] {
const m = /^(\d+)px minmax\(0, 1fr\) (\d+)px$/.exec(frame.style.gridTemplateColumns)
if (m === null) throw new Error(`unexpected template: ${frame.style.gridTemplateColumns}`)
return [Number(m[1]), Number(m[2])]
}
function drag(handle: Element, fromX: number, toX: number): void {
const down = new PointerEvent('pointerdown', { pointerId: 1, clientX: fromX, bubbles: true })
const move = new PointerEvent('pointermove', { pointerId: 1, clientX: toX, bubbles: true })
const up = new PointerEvent('pointerup', { pointerId: 1, clientX: toX, bubbles: true })
act(() => { handle.dispatchEvent(down) })
act(() => { handle.dispatchEvent(move); vi.advanceTimersByTime(20) })
act(() => { handle.dispatchEvent(up) })
}
beforeEach(() => {
frameWidth = 1920
sessionMode.current = true
localStorage.clear() // the layout store persists; instances must not bleed across tests
vi.useFakeTimers()
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => setTimeout(() => { cb(0) }, 16) as unknown as number)
vi.stubGlobal('cancelAnimationFrame', (h: number) => { clearTimeout(h) })
window.innerWidth = frameWidth
Element.prototype.getBoundingClientRect = function () {
return { width: frameWidth, height: 1080, top: 0, left: 0, right: frameWidth, bottom: 1080, x: 0, y: 0, toJSON: () => ({}) } as DOMRect
}
// jsdom lacks pointer capture: emulate per-element so hasPointerCapture gates pass.
const captured = new WeakSet<Element>()
Element.prototype.setPointerCapture = function () { captured.add(this) }
Element.prototype.releasePointerCapture = function () { captured.delete(this) }
Element.prototype.hasPointerCapture = function () { return captured.has(this) }
})
afterEach(() => {
cleanup()
vi.useRealTimers()
vi.unstubAllGlobals()
})
describe('AppFrame', () => {
it('renders three tracks from store state', () => {
const { frame } = mountFrame()
expect(tracks(frame)).toEqual([300, 360])
})
it('renders the session pair with empty owner shares (sessionId is framework-standard)', () => {
const { slotCalls, getByTestId } = mountFrame()
expect(getByTestId('center-content')).toBeTruthy()
expect(getByTestId('details-content')).toBeTruthy()
const keys = slotCalls.map((c) => c.key)
expect(keys).toContain('conversation')
expect(keys).toContain('details')
expect(keys).not.toContain('conversation.empty')
expect(slotCalls.find((c) => c.key === 'conversation')!.props).toEqual({})
expect(slotCalls.find((c) => c.key === 'details')!.props).toEqual({})
})
it('renders the empty branch through conversation.empty when no session is current', () => {
sessionMode.current = false
const { slotCalls, getByTestId, queryByTestId } = mountFrame()
expect(getByTestId('empty-content')).toBeTruthy()
expect(queryByTestId('center-content')).toBeNull()
expect(slotCalls.map((c) => c.key)).toContain('conversation.empty')
expect(slotCalls.map((c) => c.key)).not.toContain('conversation')
})
it('sidebar slot receives live concession output as owner props', () => {
const { slotCalls } = mountFrame()
expect(slotCalls.find((c) => c.key === 'sidebar')!.props).toEqual({ collapsed: false, width: 300 })
})
it('sidebar drag widens through rAF-batched pointer moves', () => {
const { frame } = mountFrame()
const handles = frame.querySelectorAll('[class*="handle"]')
drag(handles[0]!, 300, 350)
expect(tracks(frame)[0]).toBe(350)
})
it('details drag widens leftward (negative dx grows the panel)', () => {
const { frame } = mountFrame()
const handles = frame.querySelectorAll('[class*="handle"]')
drag(handles[1]!, 1560, 1500)
expect(tracks(frame)[1]).toBe(420)
})
it('drag base is the rendered (concession-clamped) width, not the preference', () => {
frameWidth = 1250 // step-2 squeeze: details renders 310 while preference is 360
const { frame, instance } = mountFrame()
expect(tracks(frame)).toEqual([300, 310])
const handles = frame.querySelectorAll('[class*="handle"]')
drag(handles[1]!, 940, 950) // shrink by 10 from the rendered width
expect(instance.getSnapshot().details).toBe(300)
})
it('details column stays mounted at zero width', () => {
const { frame, instance, getByTestId } = mountFrame()
act(() => { instance.actions.closeDetails() })
expect(tracks(frame)).toEqual([300, 0])
expect(getByTestId('details-content')).toBeTruthy()
expect(frame.hasAttribute('data-details-collapsed')).toBe(true)
})
it('closed sidebar keeps its compact rail with mounted slot content and collapsed owner props', () => {
const { frame, instance, slotCalls, getByTestId } = mountFrame()
act(() => { instance.actions.toggleSidebar() })
expect(tracks(frame)).toEqual([SIDEBAR_COLLAPSED, 360])
expect(getByTestId('sidebar-content')).toBeTruthy()
expect(frame.hasAttribute('data-sidebar-collapsed')).toBe(true)
const lastSidebarCall = slotCalls.filter((c) => c.key === 'sidebar').at(-1)!
expect(lastSidebarCall.props).toEqual({ collapsed: true, width: SIDEBAR_COLLAPSED })
})
it('viewport shrink triggers the concession chain via ResizeObserver', () => {
const { frame } = mountFrame()
frameWidth = 1250
act(() => { fireResize?.(); vi.advanceTimersByTime(20) })
expect(tracks(frame)).toEqual([300, 310])
frameWidth = 1920
act(() => { fireResize?.(); vi.advanceTimersByTime(20) })
expect(tracks(frame)).toEqual([300, 360])
})
it('drag handles disappear for collapsed columns', () => {
const { frame, instance } = mountFrame()
expect(frame.querySelectorAll('[class*="handle"]')).toHaveLength(2)
act(() => { instance.actions.closeDetails() })
expect(frame.querySelectorAll('[class*="handle"]')).toHaveLength(1)
act(() => { instance.actions.toggleSidebar() })
expect(frame.querySelectorAll('[class*="handle"]')).toHaveLength(0)
})
})
describe('AppFrame — guard branches', () => {
it('pointer moves without capture are ignored (no width write)', () => {
const { frame, instance } = mountFrame()
const handle = frame.querySelectorAll('[class*="handle"]')[0]!
const before = instance.getSnapshot().sidebar
// Move + up without a preceding pointerdown: hasPointerCapture is false.
act(() => {
handle.dispatchEvent(new PointerEvent('pointermove', { pointerId: 9, clientX: 500, bubbles: true }))
vi.advanceTimersByTime(20)
handle.dispatchEvent(new PointerEvent('pointerup', { pointerId: 9, clientX: 500, bubbles: true }))
})
expect(instance.getSnapshot().sidebar).toBe(before)
})
it('two moves inside one frame coalesce through the pending rAF', () => {
const { frame, instance } = mountFrame()
const handle = frame.querySelectorAll('[class*="handle"]')[0]!
act(() => { handle.dispatchEvent(new PointerEvent('pointerdown', { pointerId: 1, clientX: 300, bubbles: true })) })
act(() => {
// Two moves before the frame flushes: the second must ride the pending
// rAF (frame.current ??= guard), and the flush sees the latest x.
handle.dispatchEvent(new PointerEvent('pointermove', { pointerId: 1, clientX: 320, bubbles: true }))
handle.dispatchEvent(new PointerEvent('pointermove', { pointerId: 1, clientX: 340, bubbles: true }))
vi.advanceTimersByTime(20)
})
act(() => { handle.dispatchEvent(new PointerEvent('pointerup', { pointerId: 1, clientX: 340, bubbles: true })) })
expect(instance.getSnapshot().sidebar).toBe(340)
})
it('pointerup with a pending rAF cancels it and commits the final position', () => {
const { frame, instance } = mountFrame()
const handle = frame.querySelectorAll('[class*="handle"]')[0]!
act(() => { handle.dispatchEvent(new PointerEvent('pointerdown', { pointerId: 1, clientX: 300, bubbles: true })) })
act(() => {
handle.dispatchEvent(new PointerEvent('pointermove', { pointerId: 1, clientX: 360, bubbles: true }))
// No timer advance: the rAF is still pending when pointerup arrives.
handle.dispatchEvent(new PointerEvent('pointerup', { pointerId: 1, clientX: 360, bubbles: true }))
})
expect(instance.getSnapshot().sidebar).toBe(360)
})
it('zero-width resize reports are ignored (display:none window)', () => {
const { frame } = mountFrame()
frameWidth = 0
act(() => { fireResize?.(); vi.advanceTimersByTime(20) })
// Track template still reflects the last non-zero viewport.
expect(tracks(frame)).toEqual([300, 360])
})
})
describe('AppFrame — unmount with an in-flight resize frame', () => {
it('cancels the pending rAF on unmount (no post-unmount setState)', () => {
const { unmount } = mountFrame()
frameWidth = 800
act(() => { fireResize?.() }) // rAF scheduled, NOT flushed
unmount()
// Flushing after unmount must be a no-op (the frame was cancelled).
expect(() => { vi.advanceTimersByTime(20) }).not.toThrow()
})
it('double resize inside one frame rides the pending rAF (??= guard)', () => {
const { frame } = mountFrame()
frameWidth = 1250
act(() => { fireResize?.(); fireResize?.(); vi.advanceTimersByTime(20) })
expect(tracks(frame)).toEqual([300, 310])
})
})