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.
299 lines
13 KiB
TypeScript
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)
|
|
})
|
|
})
|