mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
Fixed sidebar width (never concedes), figma session-row rail (16px twist + status slots, triangle arrows, mount fade), exact brand wordmark svg with tooltip'd rail controls, form controls inheriting the app font stack, and inlined the twist button reset since the tsdown CSS pipeline drops composes.
276 lines
13 KiB
TypeScript
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 280, 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([280, 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: 280 })
|
|
})
|
|
|
|
it('sidebar drag widens through rAF-batched pointer moves', () => {
|
|
const { frame } = mountFrame()
|
|
const handles = frame.querySelectorAll('[class*="handle"]')
|
|
drag(handles[0]!, 280, 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 330 while preference is 360
|
|
const { frame, instance } = mountFrame()
|
|
expect(tracks(frame)).toEqual([280, 330])
|
|
const handles = frame.querySelectorAll('[class*="handle"]')
|
|
drag(handles[1]!, 920, 930) // shrink by 10 from the rendered width
|
|
expect(instance.getSnapshot().details).toBe(320)
|
|
})
|
|
|
|
it('details column stays mounted at zero width', () => {
|
|
const { frame, instance, getByTestId } = mountFrame()
|
|
act(() => { instance.actions.closeDetails() })
|
|
expect(tracks(frame)).toEqual([280, 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([280, 330])
|
|
frameWidth = 1920
|
|
act(() => { fireResize?.(); vi.advanceTimersByTime(20) })
|
|
expect(tracks(frame)).toEqual([280, 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: 280, 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: 280, 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([280, 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([280, 330])
|
|
})
|
|
})
|