mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-13 20:09:55 +00:00
* feat(web): add scroll-to-bottom button * fix(web): type counted scroll button props * fix(web): retarget smooth tail scroll * chore: retrigger pull request checks
313 lines
11 KiB
TypeScript
313 lines
11 KiB
TypeScript
import { act, cleanup, fireEvent, render } from '@testing-library/react'
|
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
|
import type { PropsWithChildren } from 'react'
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { I18nProvider } from '@/lib/i18n-context'
|
|
|
|
vi.mock('@/hooks/queries/useMachines', () => ({
|
|
useMachines: () => ({ machines: [] })
|
|
}))
|
|
|
|
vi.mock('@assistant-ui/react', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import('@assistant-ui/react')>()
|
|
return {
|
|
...actual,
|
|
useAuiState: (selector: (state: unknown) => unknown) => selector({
|
|
thread: { extras: undefined }
|
|
}),
|
|
unstable_useThreadMessageIds: () => [],
|
|
ThreadPrimitive: {
|
|
...actual.ThreadPrimitive,
|
|
Root: ({ children, className }: PropsWithChildren<{ className?: string }>) => (
|
|
<div className={className}>{children}</div>
|
|
),
|
|
Viewport: ({ children }: PropsWithChildren) => children,
|
|
Messages: () => null
|
|
}
|
|
}
|
|
})
|
|
|
|
import { HappyThread } from '@/components/AssistantChat/HappyThread'
|
|
import type { ApiClient } from '@/api/client'
|
|
import type { Session } from '@/types/api'
|
|
|
|
const originalScrollTo = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'scrollTo')
|
|
const originalScrollIntoView = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'scrollIntoView')
|
|
const originalResizeObserver = Object.getOwnPropertyDescriptor(globalThis, 'ResizeObserver')
|
|
let resizeCallbacks: Array<() => void> = []
|
|
|
|
class TestResizeObserver {
|
|
constructor(callback: ResizeObserverCallback) {
|
|
resizeCallbacks.push(() => callback([], this as unknown as ResizeObserver))
|
|
}
|
|
|
|
observe() {}
|
|
|
|
disconnect() {}
|
|
}
|
|
|
|
function renderThread(onViewModeChange = vi.fn(), unseenCount = 0) {
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: { queries: { retry: false } }
|
|
})
|
|
const renderHappyThread = (forceScrollToken: number) => (
|
|
<QueryClientProvider client={queryClient}>
|
|
<I18nProvider>
|
|
<HappyThread
|
|
api={{ getHubSettings: vi.fn().mockResolvedValue({ sessionSummaryContract: false, sessionSummaryInChat: false }) } as unknown as ApiClient}
|
|
session={{ metadata: {} } as Session}
|
|
sessionId="mobile-scroll-session"
|
|
metadata={null}
|
|
disabled={false}
|
|
onRefresh={vi.fn()}
|
|
onViewModeChange={onViewModeChange}
|
|
isSyncingTail={false}
|
|
messagesWarning={null}
|
|
hasMoreMessages={false}
|
|
isLoadingMoreMessages={false}
|
|
onLoadMore={vi.fn().mockResolvedValue({ status: 'exhausted' })}
|
|
onCancelLoadMore={vi.fn()}
|
|
unseenCount={unseenCount}
|
|
rawMessagesCount={1}
|
|
normalizedMessagesCount={1}
|
|
messagesVersion={1}
|
|
historyVersion={0}
|
|
forceScrollToken={forceScrollToken}
|
|
outlineOpen={false}
|
|
outlineItems={[]}
|
|
onOutlineOpenChange={vi.fn()}
|
|
/>
|
|
</I18nProvider>
|
|
</QueryClientProvider>
|
|
)
|
|
const result = render(renderHappyThread(0))
|
|
const viewport = result.container.querySelector<HTMLElement>('.chat-scroll-y')
|
|
if (!viewport) {
|
|
throw new Error('Chat viewport was not rendered')
|
|
}
|
|
Object.defineProperties(viewport, {
|
|
scrollHeight: { configurable: true, value: 1_232 },
|
|
clientHeight: { configurable: true, value: 530 }
|
|
})
|
|
act(() => {
|
|
vi.advanceTimersByTime(0)
|
|
})
|
|
return {
|
|
...result,
|
|
viewport,
|
|
onViewModeChange,
|
|
rerenderThread: (forceScrollToken: number) => result.rerender(renderHappyThread(forceScrollToken))
|
|
}
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.useFakeTimers()
|
|
resizeCallbacks = []
|
|
Object.defineProperty(globalThis, 'ResizeObserver', {
|
|
configurable: true,
|
|
value: TestResizeObserver
|
|
})
|
|
Object.defineProperty(HTMLElement.prototype, 'scrollTo', {
|
|
configurable: true,
|
|
writable: true,
|
|
value(this: HTMLElement, options: ScrollToOptions | number, y?: number) {
|
|
const requestedTop = typeof options === 'number' ? y ?? 0 : options.top ?? 0
|
|
const maxScrollTop = Math.max(0, this.scrollHeight - this.clientHeight)
|
|
this.scrollTop = Math.min(Math.max(0, requestedTop), maxScrollTop)
|
|
}
|
|
})
|
|
})
|
|
|
|
afterEach(() => {
|
|
cleanup()
|
|
vi.clearAllTimers()
|
|
vi.useRealTimers()
|
|
if (originalScrollTo) {
|
|
Object.defineProperty(HTMLElement.prototype, 'scrollTo', originalScrollTo)
|
|
} else {
|
|
Reflect.deleteProperty(HTMLElement.prototype, 'scrollTo')
|
|
}
|
|
if (originalScrollIntoView) {
|
|
Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', originalScrollIntoView)
|
|
} else {
|
|
Reflect.deleteProperty(HTMLElement.prototype, 'scrollIntoView')
|
|
}
|
|
if (originalResizeObserver) {
|
|
Object.defineProperty(globalThis, 'ResizeObserver', originalResizeObserver)
|
|
} else {
|
|
Reflect.deleteProperty(globalThis, 'ResizeObserver')
|
|
}
|
|
})
|
|
|
|
describe('mobile initial scroll settling', () => {
|
|
it('does not snap back after pointer cancellation ends a touch swipe', () => {
|
|
const { viewport, onViewModeChange } = renderThread()
|
|
expect(viewport.scrollTop).toBe(702)
|
|
|
|
const pointerDown = new Event('pointerdown', { bubbles: true })
|
|
Object.defineProperties(pointerDown, {
|
|
button: { value: 0 },
|
|
pointerType: { value: 'touch' }
|
|
})
|
|
fireEvent(viewport, pointerDown)
|
|
const pointerCancel = new Event('pointercancel', { bubbles: true })
|
|
Object.defineProperty(pointerCancel, 'pointerType', { value: 'touch' })
|
|
fireEvent(viewport, pointerCancel)
|
|
|
|
viewport.scrollTop = 520
|
|
fireEvent.scroll(viewport)
|
|
act(() => {
|
|
vi.advanceTimersByTime(1_800)
|
|
})
|
|
|
|
expect(viewport.scrollTop).toBe(520)
|
|
expect(onViewModeChange).toHaveBeenLastCalledWith('history')
|
|
})
|
|
|
|
it('keeps settling for non-explicit non-zero layout movement', () => {
|
|
const { viewport, onViewModeChange } = renderThread()
|
|
|
|
viewport.scrollTop = 520
|
|
fireEvent.scroll(viewport)
|
|
act(() => {
|
|
vi.advanceTimersByTime(1_800)
|
|
})
|
|
|
|
expect(viewport.scrollTop).toBe(702)
|
|
expect(onViewModeChange).not.toHaveBeenCalledWith('history')
|
|
})
|
|
|
|
it('does not snap back after a window-captured native scrollbar drag', () => {
|
|
const { viewport, onViewModeChange } = renderThread()
|
|
vi.spyOn(viewport, 'getBoundingClientRect').mockReturnValue({
|
|
left: 0,
|
|
top: 0,
|
|
right: 320,
|
|
bottom: 600
|
|
} as DOMRect)
|
|
|
|
fireEvent.mouseDown(window, { button: 0, clientX: 319, clientY: 200 })
|
|
viewport.scrollTop = 520
|
|
fireEvent.scroll(viewport)
|
|
fireEvent.mouseUp(window)
|
|
act(() => {
|
|
vi.advanceTimersByTime(1_800)
|
|
})
|
|
|
|
expect(viewport.scrollTop).toBe(520)
|
|
expect(onViewModeChange).toHaveBeenLastCalledWith('history')
|
|
})
|
|
|
|
it('ignores captured mouse input outside the chat viewport', () => {
|
|
const { viewport, onViewModeChange } = renderThread()
|
|
vi.spyOn(viewport, 'getBoundingClientRect').mockReturnValue({
|
|
left: 0,
|
|
top: 0,
|
|
right: 320,
|
|
bottom: 600
|
|
} as DOMRect)
|
|
|
|
fireEvent.mouseDown(window, { button: 0, clientX: 400, clientY: 200 })
|
|
viewport.scrollTop = 520
|
|
fireEvent.scroll(viewport)
|
|
fireEvent.mouseUp(window)
|
|
act(() => {
|
|
vi.advanceTimersByTime(1_800)
|
|
})
|
|
|
|
expect(viewport.scrollTop).toBe(702)
|
|
expect(onViewModeChange).not.toHaveBeenCalledWith('history')
|
|
})
|
|
|
|
it('keeps settling after the runtime resets the viewport to the exact top', () => {
|
|
const { viewport, onViewModeChange } = renderThread()
|
|
|
|
viewport.scrollTop = 0
|
|
fireEvent.scroll(viewport)
|
|
act(() => {
|
|
vi.advanceTimersByTime(1_800)
|
|
})
|
|
|
|
expect(viewport.scrollTop).toBe(702)
|
|
expect(onViewModeChange).not.toHaveBeenCalledWith('history')
|
|
})
|
|
})
|
|
|
|
describe('explicit tail scrolling', () => {
|
|
it('renders the unread count inside the compact bottom control', () => {
|
|
const { container } = renderThread(vi.fn(), 7)
|
|
const button = container.querySelector<HTMLButtonElement>('button[aria-label*="7"]')
|
|
|
|
expect(button).not.toBeNull()
|
|
expect(button).toHaveClass('rounded-full', 'h-6', 'w-6')
|
|
expect(button).toHaveClass('bg-[var(--app-button)]', 'text-[var(--app-button-text)]')
|
|
expect(button?.querySelector('span')).toHaveClass('translate-y-px')
|
|
expect(button?.textContent).toContain('7')
|
|
})
|
|
|
|
it('uses the same smooth end-alignment scroll as outline navigation', () => {
|
|
const scrollIntoView = vi.fn()
|
|
Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', {
|
|
configurable: true,
|
|
writable: true,
|
|
value: scrollIntoView
|
|
})
|
|
const { rerenderThread } = renderThread()
|
|
|
|
rerenderThread(1)
|
|
|
|
expect(scrollIntoView).toHaveBeenCalledWith({ block: 'end', behavior: 'smooth' })
|
|
})
|
|
|
|
it('retargets the smooth tail jump when content grows during the animation', () => {
|
|
const scrollIntoView = vi.fn()
|
|
Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', {
|
|
configurable: true,
|
|
writable: true,
|
|
value: scrollIntoView
|
|
})
|
|
const { rerenderThread } = renderThread()
|
|
|
|
rerenderThread(1)
|
|
expect(scrollIntoView).toHaveBeenCalledTimes(1)
|
|
|
|
act(() => {
|
|
resizeCallbacks.at(-1)?.()
|
|
})
|
|
|
|
expect(scrollIntoView).toHaveBeenCalledTimes(2)
|
|
expect(scrollIntoView).toHaveBeenLastCalledWith({ block: 'end', behavior: 'smooth' })
|
|
})
|
|
|
|
it('stays in tail mode through smooth-scroll progress and content growth', () => {
|
|
const { viewport, onViewModeChange, rerenderThread } = renderThread()
|
|
act(() => {
|
|
vi.advanceTimersByTime(1_800)
|
|
})
|
|
|
|
viewport.scrollTop = 400
|
|
fireEvent.scroll(viewport)
|
|
expect(onViewModeChange).toHaveBeenLastCalledWith('history')
|
|
|
|
Object.defineProperty(viewport, 'scrollTo', {
|
|
configurable: true,
|
|
value: vi.fn()
|
|
})
|
|
onViewModeChange.mockClear()
|
|
rerenderThread(1)
|
|
expect(onViewModeChange).toHaveBeenLastCalledWith('tail')
|
|
|
|
viewport.scrollTop = 500
|
|
fireEvent.scroll(viewport)
|
|
Object.defineProperty(viewport, 'scrollHeight', { configurable: true, value: 1_400 })
|
|
viewport.scrollTop = 650
|
|
fireEvent.scroll(viewport)
|
|
|
|
expect(onViewModeChange).not.toHaveBeenCalledWith('history')
|
|
|
|
viewport.scrollTop = 870
|
|
fireEvent.scroll(viewport)
|
|
expect(onViewModeChange).not.toHaveBeenCalledWith('history')
|
|
})
|
|
})
|