Files
deepseek-harness/packages/experimental/client-ui-forge/tests/worktree-toggle.client.spec.tsx
T

330 lines
15 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, zh } 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, translate = t) {
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={translate}
/>,
)
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' })],
['outside a Git checkout', editable({ blocker: 'not-git' }, { state: 'none' })],
])('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.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.each(['read-only', 'outside-writable-root'] as const)('keeps the selected base visible after %s blocks a request until it is withdrawn', async (blocker) => {
const b = option(editable({ request: { from: 'release' } }))
b.publish(editable({ blocker, request: { from: 'release' } }))
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(b.view.getByRole('button', { name: 'From release' }).textContent).toBe('From release')
expect(hint(b)).toBe(en[`worktree.blocked.${blocker}`])
vi.useRealTimers()
await act(async () => { fireEvent.click(b.checkbox()) })
expect(b.cancel).toHaveBeenCalledOnce()
expect(b.request).not.toHaveBeenCalled()
b.publish(editable({ blocker }))
expect(b.checkbox().checked).toBe(false)
expect(b.checkbox().disabled).toBe(true)
expect(b.view.getByRole('button', { name: 'Current branch: main' })).toBeTruthy()
})
it('preserves a HEAD base while blocked and restores the selected branch menu when permission returns', () => {
const b = option(editable({ blocker: 'read-only', request: { from: null } }))
expect(b.view.getByRole('button', { name: en['worktree.baseHead'] })).toBeTruthy()
b.publish(editable({ blocker: 'read-only', request: { from: 'release' } }))
b.publish(editable({ request: { from: 'release' } }))
expect(b.base()?.textContent).toBe('From release')
expect(b.checkbox().checked).toBe(true)
})
it.each([
['en', en, 'Current branch: main', 'Not on a branch · 0a1b2c3'],
['zh', zh, '当前分支:main', '未在分支上 · 0a1b2c3'],
] as const)('names the branch and detached checkout in an informational tooltip (%s)', (_locale, copy, branchLabel, detachedLabel) => {
const b = option(editable(), makeTranslate(copy))
const chip = b.view.getByRole('button', { name: branchLabel })
fireEvent.click(chip)
expect(screen.getByRole('tooltip').textContent).toBe(branchLabel)
expect(chip.getAttribute('aria-describedby')).toBe(screen.getByRole('tooltip').id)
fireEvent.click(chip)
expect(screen.queryByRole('tooltip')).toBeNull()
b.publish(ready({ worktree: worktree({ editable: true }) }, { branch: null }))
const detached = b.view.getByRole('button', { name: detachedLabel })
fireEvent.click(detached)
expect(screen.getByRole('tooltip').textContent).toBe(detachedLabel)
fireEvent.click(detached)
b.publish(ready({ worktree: worktree({ editable: true }) }, { branch: null, commit: null }))
expect(b.view.getByRole('button', { name: copy['checkout.detached'] })).toBeTruthy()
expect(b.request).not.toHaveBeenCalled()
expect(b.branches).not.toHaveBeenCalled()
})
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('shows the current branch without requesting a worktree 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'])
const current = b.view.getByText('main')
fireEvent.click(current)
expect(b.request).not.toHaveBeenCalled()
expect(b.branches).not.toHaveBeenCalled()
})
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 }))
expect(b.view.getByText(`${en['checkout.detached']} · 0a1b2c3`)).toBeTruthy()
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 shows the current 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)
})
})