Files
deepseek-harness/packages/experimental/client-ui-forge/tests/worktree-toggle.client.spec.tsx
T
Yichen Jiang 4b07c32b93 fix(forge): follow master's hover, tooltip, and plugin metadata rules
Master now limits CSS hover styles to hover-capable primary input, opens
tooltips from pointer events, requires a model on every Agent and a model
selection on agent/pre-step, and requires Japanese plugin metadata.

- The panel's hover styles move under @media (hover: hover); expanded,
  pressed, and focus-visible styles stay outside it.
- The hover tests enter with pointer events.
- The test Agent carries an unsupported model, and proposed steps carry a
  model selection.
- The panel and the forge bundle ship Japanese titles and descriptions.
2026-10-22 17:45:15 +08:00

299 lines
13 KiB
TypeScript

// @vitest-environment jsdom
/** New Session worktree option: when it shows, why it is disabled, the optimistic checkbox, the base menu, and a running creation. */
import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import menuCss from '@deepseek-ai/dsh-client-ui-primitives/src/Menu.module.css'
import { en } from '../src/client/locales.ts'
import type { PanelSnapshot } from '../src/client/source.ts'
import { WorktreeToggle } from '../src/client/WorktreeToggle.tsx'
import type { ForgeBranches, ForgeCheckoutState, ForgePanelState, ForgePanelWorktree, ForgeWorktreeBlocker } from '../src/types.ts'
import { creation, failure, ready, worktree } from './client-fixtures.client.ts'
afterEach(() => {
cleanup()
vi.useRealTimers()
})
const t = makeTranslate(en)
const BRANCHES: ForgeBranches = { current: 'main', branches: ['main', 'release', 'feat/x'] }
/** Panel state of a Session that can still start in a worktree. */
function editable(overrides: Partial<ForgePanelWorktree> = {}, checkout?: ForgeCheckoutState): ForgePanelState {
const state = ready({ worktree: worktree({ editable: true, ...overrides }) })
return checkout === undefined ? state : { ...state, checkout }
}
/** A promise the test settles by hand. */
function deferred<T>() {
let resolve: (value: T) => void = () => {}
const promise = new Promise<T>((settle) => { resolve = settle })
return { promise, resolve }
}
function option(state: ForgePanelState | null) {
const panel = createSnapshotStore<PanelSnapshot>({ state, disconnected: false })
const request = vi.fn(async (_from: string | null) => true)
const cancel = vi.fn(async () => true)
const branches = vi.fn(async (): Promise<ForgeBranches | undefined> => BRANCHES)
const view = render(
<WorktreeToggle
usePanel={bindSnapshotSelector(panel)}
request={request}
cancel={cancel}
branches={branches}
t={t}
/>,
)
const checkbox = (): HTMLInputElement => {
const input = view.getByRole('checkbox', { name: en['worktree.label'] })
if (!(input instanceof HTMLInputElement)) throw new Error('no checkbox')
return input
}
const base = (): HTMLButtonElement | null => {
const button = view.container.querySelector('button[aria-haspopup="menu"]')
return button instanceof HTMLButtonElement ? button : null
}
const publish = (next: ForgePanelState): void => {
act(() => { panel.set({ state: next, disconnected: false }) })
}
return { view, panel, request, cancel, branches, checkbox, base, publish }
}
/** Open the base branch menu and wait for its branches. */
async function openMenu(b: ReturnType<typeof option>): Promise<HTMLElement> {
await act(async () => { fireEvent.click(b.base()!) })
return screen.getByRole('menu')
}
/** The tooltip of the option after the pointer rests on it. */
function hint(b: ReturnType<typeof option>): string | null {
vi.useFakeTimers()
const label = b.checkbox().closest('label')
if (label === null) throw new Error('no option label')
fireEvent.pointerEnter(label)
act(() => { vi.advanceTimersByTime(500) })
return screen.getByRole('tooltip').textContent
}
describe('WorktreeToggle visibility', () => {
it.each([
['before the first state', null],
['once the Session can no longer change its worktree', ready()],
['while the first inspection runs', editable({}, { state: 'pending' })],
['when the inspection failed', editable({}, { state: 'failed', message: 'git status failed' })],
])('renders nothing %s', (_label, state) => {
expect(option(state).view.container.innerHTML).toBe('')
})
it('appears and disappears with the editable state', () => {
const b = option(ready())
b.publish(editable())
expect(b.checkbox().checked).toBe(false)
b.publish(ready({ worktree: worktree({ editable: false, request: { from: 'main' } }) }))
expect(b.view.container.innerHTML).toBe('')
})
})
describe('WorktreeToggle checkbox', () => {
it('disables the option outside a Git checkout and says why', () => {
const b = option(editable({ blocker: 'not-git' }, { state: 'none' }))
expect(b.checkbox().disabled).toBe(true)
expect(b.checkbox().checked).toBe(false)
expect(b.checkbox().closest('label')?.hasAttribute('data-disabled')).toBe(true)
expect(b.base()).toBeNull()
expect(hint(b)).toBe(en['worktree.blocked.not-git'])
})
it.each<[ForgeWorktreeBlocker, string]>([
['read-only', en['worktree.blocked.read-only']],
['outside-writable-root', en['worktree.blocked.outside-writable-root']],
])('disables the option of a Git checkout blocked as %s and says why', (blocker, reason) => {
const b = option(editable({ blocker }))
expect(b.checkbox().disabled).toBe(true)
expect(b.checkbox().checked).toBe(false)
expect(b.checkbox().closest('label')?.hasAttribute('data-disabled')).toBe(true)
expect(b.base()).toBeNull()
expect(hint(b)).toBe(reason)
})
it('keeps an open request of a blocked checkout checked, without its base menu, until it is withdrawn', async () => {
const b = option(editable({ blocker: 'read-only', request: { from: 'main' } }))
expect(b.checkbox().checked).toBe(true)
expect(b.checkbox().disabled).toBe(false)
expect(b.checkbox().closest('label')?.hasAttribute('data-disabled')).toBe(false)
expect(b.base()).toBeNull()
expect(hint(b)).toBe(en['worktree.blocked.read-only'])
vi.useRealTimers()
await act(async () => { fireEvent.click(b.checkbox()) })
expect(b.cancel).toHaveBeenCalledOnce()
expect(b.request).not.toHaveBeenCalled()
b.publish(editable({ blocker: 'read-only' }))
expect(b.checkbox().checked).toBe(false)
expect(b.checkbox().disabled).toBe(true)
})
it('offers the option again once the blocker clears', () => {
const b = option(editable({ blocker: 'read-only' }))
b.publish(editable())
expect(b.checkbox().disabled).toBe(false)
expect(b.checkbox().closest('label')?.hasAttribute('data-disabled')).toBe(false)
expect(hint(b)).toBe(en['worktree.hint'])
})
it('offers the option unchecked inside a Git checkout', () => {
const b = option(editable())
expect(b.checkbox().disabled).toBe(false)
expect(b.checkbox().checked).toBe(false)
expect(b.checkbox().closest('label')?.hasAttribute('data-disabled')).toBe(false)
expect(b.base()).toBeNull()
expect(hint(b)).toBe(en['worktree.hint'])
})
it('requests a worktree from the current branch and holds the checked state until the Host answers', async () => {
const b = option(editable())
const answer = deferred<boolean>()
b.request.mockReturnValueOnce(answer.promise)
fireEvent.click(b.checkbox())
expect(b.request).toHaveBeenCalledWith('main')
expect(b.checkbox().checked).toBe(true)
expect(b.checkbox().disabled).toBe(true)
expect(b.base()?.textContent).toBe('From main')
b.publish(editable({ request: { from: 'main' } }))
await act(async () => { answer.resolve(true) })
expect(b.checkbox().checked).toBe(true)
expect(b.checkbox().disabled).toBe(false)
expect(b.base()?.textContent).toBe('From main')
})
it('requests a worktree from HEAD on a detached checkout', async () => {
const b = option(ready({ worktree: worktree({ editable: true }) }, { branch: null }))
await act(async () => { fireEvent.click(b.checkbox()) })
expect(b.request).toHaveBeenCalledWith(null)
b.publish(ready({ worktree: worktree({ editable: true, request: { from: null } }) }, { branch: null }))
expect(b.base()?.textContent).toBe(en['worktree.baseHead'])
})
it('returns to the Host state when the Host refuses the request', async () => {
const b = option(editable())
b.request.mockResolvedValueOnce(false)
await act(async () => { fireEvent.click(b.checkbox()) })
expect(b.checkbox().checked).toBe(false)
expect(b.checkbox().disabled).toBe(false)
expect(b.base()).toBeNull()
})
it('withdraws an open request and hides the base branch until the Host answers', async () => {
const b = option(editable({ request: { from: 'release' } }))
expect(b.checkbox().checked).toBe(true)
expect(b.base()?.textContent).toBe('From release')
const answer = deferred<boolean>()
b.cancel.mockReturnValueOnce(answer.promise)
fireEvent.click(b.checkbox())
expect(b.cancel).toHaveBeenCalledOnce()
expect(b.request).not.toHaveBeenCalled()
expect(b.checkbox().checked).toBe(false)
expect(b.checkbox().disabled).toBe(true)
expect(b.base()).toBeNull()
b.publish(editable())
await act(async () => { answer.resolve(true) })
expect(b.checkbox().checked).toBe(false)
expect(b.checkbox().disabled).toBe(false)
})
})
describe('WorktreeToggle base branch menu', () => {
it('names the current branch for a request from HEAD', () => {
const b = option(editable({ request: { from: null } }))
expect(b.base()?.textContent).toBe('From main')
})
it('sits before the checkbox and opens below its anchor, aligned to the anchor\'s right edge', async () => {
const b = option(editable({ request: { from: 'main' } }))
const anchor = b.base()
if (anchor === null) throw new Error('no base branch anchor')
expect(anchor.compareDocumentPosition(b.checkbox()) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
const wrapper = anchor.parentElement
if (wrapper === null) throw new Error('no menu wrapper')
vi.spyOn(wrapper, 'getBoundingClientRect').mockReturnValue(new DOMRect(100, 40, 120, 28))
const menu = await openMenu(b)
expect(menu.style.left).toBe('220px')
expect(menu.style.top).toBe('72px')
})
it('lists the local branches, marks the current and the chosen one, and requests another base', async () => {
const b = option(editable({ request: { from: 'main' } }))
const answer = deferred<ForgeBranches | undefined>()
b.branches.mockReturnValueOnce(answer.promise)
fireEvent.click(b.base()!)
expect(b.branches).toHaveBeenCalledOnce()
expect(b.base()?.disabled).toBe(true)
expect(screen.queryByRole('menu')).toBeNull()
await act(async () => { answer.resolve(BRANCHES) })
expect(b.base()?.disabled).toBe(false)
expect(b.base()?.getAttribute('aria-expanded')).toBe('true')
const menu = screen.getByRole('menu')
expect(within(menu).getByText(en['worktree.baseMenu'])).toBeTruthy()
const items = within(menu).getAllByRole('menuitem')
expect(items.map(item => item.textContent)).toEqual([`main${en['worktree.currentBranch']}`, 'release', 'feat/x'])
expect(items.map(item => item.matches(`.${menuCss.selected}`))).toEqual([true, false, false])
fireEvent.click(items[1]!)
expect(b.request).toHaveBeenCalledWith('release')
expect(screen.queryByRole('menu')).toBeNull()
})
it('closes without a request when the chosen base is selected again', async () => {
const b = option(editable({ request: { from: 'release' } }))
const menu = await openMenu(b)
fireEvent.click(within(menu).getByRole('menuitem', { name: 'release' }))
expect(b.request).not.toHaveBeenCalled()
expect(screen.queryByRole('menu')).toBeNull()
})
it('closes from its anchor and on Escape', async () => {
const b = option(editable({ request: { from: 'main' } }))
await openMenu(b)
fireEvent.click(b.base()!)
expect(screen.queryByRole('menu')).toBeNull()
expect(b.branches).toHaveBeenCalledOnce()
await openMenu(b)
fireEvent.keyDown(document, { key: 'Escape' })
expect(screen.queryByRole('menu')).toBeNull()
expect(b.request).not.toHaveBeenCalled()
})
it('stays closed when the branches cannot be read', async () => {
const b = option(editable({ request: { from: 'main' } }))
b.branches.mockResolvedValueOnce(undefined)
await act(async () => { fireEvent.click(b.base()!) })
expect(screen.queryByRole('menu')).toBeNull()
expect(b.base()?.disabled).toBe(false)
expect(b.base()?.getAttribute('aria-expanded')).toBe('false')
})
})
describe('WorktreeToggle while the worktree is created', () => {
it('freezes the checkbox and the base branch until the creation fails, which leaves both open to change', () => {
const b = option(editable({ request: { from: 'main' }, creation: creation() }))
expect(b.checkbox().checked).toBe(true)
expect(b.checkbox().disabled).toBe(true)
expect(b.checkbox().closest('label')?.hasAttribute('data-disabled')).toBe(true)
expect(b.base()?.disabled).toBe(true)
b.publish(editable({ request: { from: 'main' }, failure: failure() }))
expect(b.checkbox().checked).toBe(true)
expect(b.checkbox().disabled).toBe(false)
expect(b.checkbox().closest('label')?.hasAttribute('data-disabled')).toBe(false)
expect(b.base()?.disabled).toBe(false)
})
})