Files
deepseek-harness/packages/client/ui-conversation/tests/input-bar.spec.tsx
T

681 lines
31 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @vitest-environment jsdom
// InputBar behavior over the machine wiring: Enter-send semantics (IME guard,
// shift newline, ctrl/meta insert, repeat suppression), queue-cut-1 running
// semantics (input stays free; primary turns stop), the machine pending lock,
// decoration backdrop, error/notice strips, and the focus-keeping mousedown.
import { afterEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render } from '@testing-library/react'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
import type { ClientContext, ConversationSnapshot, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import { SessionInputShell } from '../src/client/input/facade.ts'
import { InputBar } from '../src/client/skeleton/InputBar.tsx'
import type { InputBarProps } from '../src/client/skeleton/InputBar.tsx'
import { zh } from '../src/client/locales.ts'
afterEach(cleanup)
const SCTX = {} as ClientContext
const SID = 's1' as SessionId
function snapshotOf(overrides: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
return {
sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
promptError: null, blank: false, subagent: null, lastAgentError: null,
...overrides,
}
}
interface BenchOptions {
planEntry?: React.ReactNode
/** The `plan` projection value the standard-kit useProjection serves. */
plan?: { active: boolean; pending: boolean }
modelEntry?: React.ReactNode
/** Hot text-ref lexicon (injects a minimal slash stub exposing only lexicon()). */
lexicon?: ReadonlyMap<'/' | '@', readonly string[]>
permissions?: { options: { value: string; name: string; description?: string }[]; currentValue: string }
draft?: string
running?: boolean
subagent?: Exclude<ConversationSnapshot['subagent'], null>
disabled?: boolean
promptError?: ConversationSnapshot['promptError']
variant?: 'hero' | 'composer'
placeholder?: string
t?: InputBarProps['t']
command?: (line: string) => Promise<boolean>
accessory?: React.ReactNode
overlay?: React.ReactNode
leftItems?: React.ReactNode
rightItems?: React.ReactNode
commandMenuOpen?: boolean
toggleCommandMenu?: (selection: { start: number; end: number }) => void
}
/** Real machine behind the bar entry: sink spy, no slash pipeline (plain text goes straight to the sink). */
function bench(over?: BenchOptions) {
const sink = vi.fn()
const lex = over?.lexicon
type ShellDeps = ConstructorParameters<typeof SessionInputShell>[0]
const shell = new SessionInputShell({
actx: SCTX,
defaultSink: sink,
// Lexicon-only stub: adjudication untouched (undefined slash methods are
// never reached — these benches drive plain-draft flows only).
...(lex !== undefined
? {
slash: (() => ({
lexicon: { getSnapshot: () => lex, subscribe: () => () => {} },
})) as unknown as NonNullable<ShellDeps['slash']>,
}
: {}),
})
if (over?.draft !== undefined && over.draft !== '') shell.setDraft(over.draft)
const session = createSnapshotStore<ConversationSnapshot>(snapshotOf({
running: over?.running ?? false,
subagent: over?.subagent ?? null,
removed: over?.disabled ?? false,
promptError: over?.promptError ?? null,
}))
const stop = vi.fn()
const menuLauncher = createSnapshotStore<string | null>(over?.commandMenuOpen === true ? 'command' : null)
const slotCalls: { key: string; owner: unknown }[] = []
const renderSlot = ((key: string, owner: object) => {
slotCalls.push({ key, owner })
if (key === 'conversation.input.plan') return over?.planEntry ?? null
if (key === 'conversation.input.model') return over?.modelEntry ?? null
return null
}) as InputBarProps['renderSlot']
const props: InputBarProps = {
sessionId: SID,
SessionProvider: ({ children }) => children(SID),
useSession: bindSnapshotSelector(session),
useSessions: bindSnapshotSelector(createSnapshotStore({
ids: [], byId: {}, current: undefined, phase: 'ready',
subagentsByParent: {}, currentAddress: undefined,
})),
useWorkspaces: bindSnapshotSelector(createSnapshotStore({
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
baselinesReady: true, recentWorkspaceId: undefined,
})),
useProjection: ((key: string, selector?: (v: unknown) => unknown) =>
(selector ?? (v => v))(key === 'permissions' ? over?.permissions : key === 'plan' ? over?.plan : undefined)),
useInput: bindSnapshotSelector(shell.state),
inputActions: shell.actions,
keyboard: shell,
toggleCommandMenu: over?.toggleCommandMenu ?? vi.fn(),
useNotices: bindSnapshotSelector(shell.notices),
useLexicon: bindSnapshotSelector(shell.lexicon),
useMenuLauncher: bindSnapshotSelector(menuLauncher),
stop,
command: over?.command ?? (() => Promise.resolve(true)),
// Mirrors the real lookup chain (conversation namespace, then common).
t: over?.t ?? makeTranslate(zh, commonZh),
renderSlot,
variant: over?.variant ?? 'composer',
...(over?.placeholder !== undefined ? { placeholder: over.placeholder } : {}),
...(over?.accessory !== undefined ? { accessory: over.accessory } : {}),
...(over?.overlay !== undefined ? { overlay: over.overlay } : {}),
...(over?.leftItems !== undefined ? { leftItems: over.leftItems } : {}),
...(over?.rightItems !== undefined ? { rightItems: over.rightItems } : {}),
}
const view = render(<InputBar {...props} />)
const textarea = view.container.querySelector('textarea')!
// aria-label (not role name): title carries the same label and would double-match.
const stopping = over?.running === true && over.subagent === undefined
const button = view.container.querySelector<HTMLButtonElement>(
`button[aria-label="${stopping ? '停止生成' : '发送消息'}"]`,
)!
return { view, textarea, button, props, sink, shell, wiring: shell, session, stop, slotCalls, menuLauncher }
}
describe('Enter semantics', () => {
it('plain Enter submits queue mode through the machine; repeat and empty are suppressed', () => {
const { textarea, sink } = bench({ draft: 'hello' })
fireEvent.keyDown(textarea, { key: 'Enter' })
expect(sink).toHaveBeenCalledWith('hello')
fireEvent.keyDown(textarea, { key: 'Enter', repeat: true })
expect(sink).toHaveBeenCalledTimes(1)
const empty = bench({ draft: ' ' })
fireEvent.keyDown(empty.textarea, { key: 'Enter' })
expect(empty.sink).not.toHaveBeenCalled()
})
it('non-Enter keys and Shift+Enter fall through to native behavior', () => {
const { textarea, sink } = bench({ draft: 'hello' })
fireEvent.keyDown(textarea, { key: 'a' })
fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: true })
expect(sink).not.toHaveBeenCalled()
})
it('Shift+Enter newline wins even inside IME composition (unconditional precedence)', () => {
const { textarea, sink } = bench({ draft: 'hello' })
fireEvent.compositionStart(textarea)
fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: true })
expect(sink).not.toHaveBeenCalled() // and not preventDefault'd: native newline
})
it('Ctrl/Meta+Enter inserts a newline through the machine (no browser execCommand)', () => {
const { textarea, shell, sink } = bench({ draft: 'hello' })
textarea.setSelectionRange(5, 5)
fireEvent.keyDown(textarea, { key: 'Enter', ctrlKey: true })
expect(shell.snapshot.draft).toBe('hello\n')
expect(sink).not.toHaveBeenCalled()
})
it('platform undo/redo chords route to the machine, never the browser stack', () => {
const { textarea, shell } = bench({ draft: '' })
fireEvent.change(textarea, { target: { value: 'first' } })
fireEvent.change(textarea, { target: { value: 'first second' } })
fireEvent.keyDown(textarea, { key: 'z', ctrlKey: true })
expect(shell.snapshot.draft).not.toBe('first second')
fireEvent.keyDown(textarea, { key: 'z', ctrlKey: true, shiftKey: true })
expect(shell.snapshot.draft).toBe('first second')
})
it('composition Enter never sends: ref guard, isComposing, and keyCode 229 paths', () => {
vi.useFakeTimers()
try {
const { textarea, sink } = bench({ draft: 'hello' })
fireEvent.compositionStart(textarea)
fireEvent.keyDown(textarea, { key: 'Enter' })
expect(sink).not.toHaveBeenCalled()
fireEvent.compositionEnd(textarea)
// Safari delivers the closing keydown before the deferred clear.
fireEvent.keyDown(textarea, { key: 'Enter' })
expect(sink).not.toHaveBeenCalled()
vi.advanceTimersByTime(20)
fireEvent.keyDown(textarea, { key: 'Enter', keyCode: 229 })
expect(sink).not.toHaveBeenCalled()
fireEvent.keyDown(textarea, { key: 'Enter' })
expect(sink).toHaveBeenCalledTimes(1)
} finally {
vi.useRealTimers()
}
})
})
describe('running and lock semantics (queue cut 1)', () => {
it('running keeps the input free (typing + Enter queue) while the primary turns stop', () => {
const { textarea, button, stop, sink } = bench({ running: true, draft: '排队消息' })
expect(textarea.disabled).toBe(false) // running no longer locks
fireEvent.change(textarea, { target: { value: '排队消息2' } })
fireEvent.keyDown(textarea, { key: 'Enter' })
expect(sink).toHaveBeenCalledWith('排队消息2')
expect(button.getAttribute('aria-label')).toBe('停止生成')
fireEvent.click(button)
expect(stop).toHaveBeenCalledTimes(1)
})
it('running subagent primary admits a follow-up instead of exposing Stop', () => {
const { button, sink, stop } = bench({
running: true,
draft: '后续消息',
subagent: {
address: {
parentSessionId: 'parent' as SessionId,
childSessionId: SID,
mode: 'continuable',
},
parentAvailable: true,
},
})
expect(button.getAttribute('aria-label')).toBe('发送消息')
fireEvent.click(button)
expect(sink).toHaveBeenCalledWith('后续消息')
expect(stop).not.toHaveBeenCalled()
const empty = bench({
running: true,
subagent: {
address: {
parentSessionId: 'parent' as SessionId,
childSessionId: SID,
mode: 'continuable',
},
parentAvailable: true,
},
})
expect(empty.button.disabled).toBe(true)
})
it('disabled (session removed) locks the textarea and chrome', () => {
const { textarea, view } = bench({ disabled: true })
expect(textarea.disabled).toBe(true)
expect(textarea.placeholder).toBe('会话不可用')
expect((view.getByLabelText('命令') as HTMLButtonElement).disabled).toBe(true)
})
it('idle primary sends and disables on empty draft', () => {
const { button, sink } = bench({ draft: 'go' })
fireEvent.click(button)
expect(sink).toHaveBeenCalledWith('go')
const empty = bench()
expect(empty.button.disabled).toBe(true)
})
it('unlock refocuses the textarea; mousedown on the button keeps focus', () => {
const first = bench({ disabled: true, draft: 'x' })
act(() => { first.session.set(snapshotOf({ removed: false })) })
const textarea = first.view.container.querySelector('textarea')!
expect(document.activeElement).toBe(textarea)
textarea.blur()
fireEvent.mouseDown(first.view.container.querySelector('button[aria-label="发送消息"]')!)
expect(document.activeElement).toBe(textarea)
})
it('typing forwards through the machine (draft state echoes back)', () => {
const { textarea, wiring } = bench()
fireEvent.change(textarea, { target: { value: 'typed' } })
expect(wiring.state.getSnapshot().draft).toBe('typed')
expect((textarea).value).toBe('typed')
})
it('wheel over a non-overflowing textarea forwards to the conversation host', () => {
const host = document.createElement('div')
host.setAttribute('data-conversation-scroll', '')
Object.defineProperty(host, 'scrollTop', { value: 40, writable: true, configurable: true })
const { view, textarea } = bench()
host.appendChild(view.container)
document.body.appendChild(host)
try {
const wheeled = fireEvent.wheel(textarea, { deltaY: 30 })
expect(wheeled).toBe(false) // preventDefault
expect(host.scrollTop).toBe(70)
} finally {
host.remove()
}
})
it('wheel chains: long drafts scroll inside the textarea until each edge, then the host', () => {
const host = document.createElement('div')
host.setAttribute('data-conversation-scroll', '')
Object.defineProperty(host, 'scrollTop', { value: 40, writable: true, configurable: true })
const { view, textarea } = bench()
host.appendChild(view.container)
document.body.appendChild(host)
Object.defineProperty(textarea, 'clientHeight', { value: 100, configurable: true })
Object.defineProperty(textarea, 'scrollHeight', { value: 400, configurable: true })
let scrollTop = 150
Object.defineProperty(textarea, 'scrollTop', {
configurable: true,
get: () => scrollTop,
set: (value: number) => { scrollTop = value },
})
try {
// Mid-draft: both directions stay local — host must not move.
expect(fireEvent.wheel(textarea, { deltaY: 30 })).toBe(true)
expect(fireEvent.wheel(textarea, { deltaY: -30 })).toBe(true)
expect(host.scrollTop).toBe(40)
// At the bottom edge, further down-scroll forwards to the host.
scrollTop = 300
expect(fireEvent.wheel(textarea, { deltaY: 30 })).toBe(false)
expect(host.scrollTop).toBe(70)
// At the top edge, further up-scroll forwards to the host.
scrollTop = 0
host.scrollTop = 70
expect(fireEvent.wheel(textarea, { deltaY: -20 })).toBe(false)
expect(host.scrollTop).toBe(50)
} finally {
host.remove()
}
})
it('the decoration backdrop tracks the textarea offset (it paints every visible glyph)', () => {
const { view, textarea } = bench({ draft: 'line\n'.repeat(40) })
const backdrop = view.container.querySelector<HTMLElement>('[data-input-backdrop]')!
Object.defineProperty(backdrop, 'scrollTop', { value: 0, writable: true, configurable: true })
Object.defineProperty(textarea, 'scrollTop', { value: 0, writable: true, configurable: true })
// A scrolled draft: the textarea moves, the clipped backdrop must follow.
textarea.scrollTop = 120
fireEvent.scroll(textarea)
expect(backdrop.scrollTop).toBe(120)
// Every later move tracks too, including back to the top — a one-shot
// mirror would leave the glyphs parked at the first offset it saw.
textarea.scrollTop = 0
fireEvent.scroll(textarea)
expect(backdrop.scrollTop).toBe(0)
})
it('the backdrop carries the trailing-line sentinel that keeps its extent equal to the textarea', () => {
// jsdom has no layout, so the HEIGHTS this protects cannot be asserted here
// (the browser scenario owns that); what is checkable is that the backdrop's
// text is the draft plus exactly one newline. A textarea reserves a line box
// after a final newline and `pre-wrap` collapses one, so without the
// sentinel a draft ending in a newline leaves the backdrop a line short and
// the mirrored offset clamps.
const withNewline = bench({ draft: 'alpha\nbeta\n' })
const backdrop = withNewline.view.container.querySelector<HTMLElement>('[data-input-backdrop]')!
expect(backdrop.textContent).toBe('alpha\nbeta\n\n')
const withoutNewline = bench({ draft: 'alpha\nbeta' })
const plain = withoutNewline.view.container.querySelector<HTMLElement>('[data-input-backdrop]')!
expect(plain.textContent).toBe('alpha\nbeta\n')
})
it('disabled state shows the unavailable placeholder; custom placeholder wins', () => {
const { textarea } = bench({ disabled: true })
expect(textarea.placeholder).toBe('会话不可用')
const live = bench()
expect(live.textarea.placeholder).toBe('给智能体发消息')
const custom = bench({ placeholder: 'Custom placeholder' })
expect(custom.textarea.placeholder).toBe('Custom placeholder')
})
it('the plan projection swaps the placeholder while its effective target is plan mode', () => {
const active = bench({ plan: { active: true, pending: false } })
expect(active.textarea.placeholder).toBe('描述你的任务以生成计划')
// /plan just ran: pending entry already reads as the plan target.
const entering = bench({ plan: { active: false, pending: true } })
expect(entering.textarea.placeholder).toBe('描述你的任务以生成计划')
// Pending exit: target is default again.
const leaving = bench({ plan: { active: true, pending: true } })
expect(leaving.textarea.placeholder).toBe('给智能体发消息')
// Owner placeholder outranks the plan swap.
const custom = bench({ plan: { active: true, pending: false }, placeholder: 'Custom placeholder' })
expect(custom.textarea.placeholder).toBe('Custom placeholder')
})
})
describe('machine pending lock', () => {
it('submitting renders read-only textarea, pending dot, and a disabled primary', () => {
const { view, shell } = bench()
// Drive the machine into submitting through a claim + enter.
act(() => {
shell.setDraft('/goal ')
shell.beginCommand(
{
token: '/goal ',
submit: () => new Promise<never>(() => {}), // never settles: stays submitting
},
{ start: 0, end: 6, draftRev: shell.snapshot.draftRev },
)
shell.submit()
})
expect(shell.snapshot.phase).toBe('submitting')
const textarea = view.container.querySelector('textarea')!
expect(textarea.readOnly).toBe(true)
expect(view.container.querySelector<HTMLButtonElement>('button[aria-label="发送消息"]')!.disabled).toBe(true)
})
})
describe('decorations', () => {
it('claimed token renders the mirror highlight and the blank-args hint', () => {
// Dictionary-less stub: an unmatched hint key keeps the machine's raw hint.
const { view, shell } = bench({ t: makeTranslate({}) })
act(() => {
shell.setDraft('/goal ')
shell.beginCommand(
{ token: '/goal ', hint: '目标内容', submit: () => Promise.resolve({ kind: 'success' as const }) },
{ start: 0, end: 6, draftRev: shell.snapshot.draftRev },
)
})
const token = view.container.querySelector('[data-decoration="token"]')
expect(token?.textContent).toBe('/goal ')
expect(view.container.querySelector('[data-decoration="hint"]')?.textContent).toBe('目标内容')
// Args typed: the hint disappears, the token highlight stays.
act(() => { shell.setDraft('/goal 发布') })
expect(view.container.querySelector('[data-decoration="hint"]')).toBeNull()
expect(view.container.querySelector('[data-decoration="token"]')).not.toBeNull()
})
it('a locale entry for the claimed command overrides the raw claim hint (trailing-space token)', () => {
const { view, shell } = bench()
act(() => {
shell.setDraft('/goal ')
shell.beginCommand(
{ token: '/goal ', hint: '[<objective>|clear|edit <objective>|pause|resume]', submit: () => Promise.resolve({ kind: 'success' as const }) },
{ start: 0, end: 6, draftRev: shell.snapshot.draftRev },
)
})
expect(view.container.querySelector('[data-decoration="hint"]')?.textContent).toBe('输入目标,智能体将持续执行')
})
it('an inserted reference renders as a chip at its placeholder offset', () => {
const { view, shell } = bench()
act(() => {
shell.setDraft('参考 @w1 内容')
shell.insertReference(
{ source: 'subagent', ref: 'w1', label: '@w1', clipboardText: '@w1' },
{ start: 3, end: 6, draftRev: shell.snapshot.draftRev },
)
})
const chip = view.container.querySelector('[data-decoration="chip"]')
expect(chip?.textContent).toBe('@w1')
expect(shell.snapshot.occurrences).toHaveLength(1)
// The draft carries exactly one placeholder char where the token was.
expect(shell.snapshot.draft).toBe('参考 \uFFFC 内容')
})
it('a lexicon-matched plain token renders the text-ref mark (decision 21)', () => {
const lexicon = new Map<'/' | '@', readonly string[]>([['/', ['fixture-demo']]])
const { view, shell } = bench({ lexicon })
act(() => { shell.setDraft('use /fixture-demo now') })
const mark = view.container.querySelector('[data-decoration="text-ref"]')
expect(mark?.textContent).toBe('/fixture-demo')
// Editing the token out of match shape drops the decoration.
act(() => { shell.setDraft('use /fixture-dem now') })
expect(view.container.querySelector('[data-decoration="text-ref"]')).toBeNull()
})
})
describe('insertText (decision 21 scoped event body)', () => {
it('splices plain text over the span and reports success as true', () => {
const { shell } = bench({ draft: '/fix' })
const ok = shell.insertText('/fixture-demo ', { start: 0, end: 4, draftRev: shell.snapshot.draftRev })
expect(ok).toBe(true)
expect(shell.snapshot.draft).toBe('/fixture-demo ')
expect(shell.snapshot.occurrences).toEqual([])
})
it('a stale draftRev refuses whole: false, draft untouched', () => {
const { shell } = bench({ draft: '/fix' })
const span = { start: 0, end: 4, draftRev: shell.snapshot.draftRev }
act(() => { shell.setDraft('/fixX') })
expect(shell.insertText('/fixture-demo ', span)).toBe(false)
expect(shell.snapshot.draft).toBe('/fixX')
})
})
describe('strips and variants', () => {
it('derives the failure strip from promptError (ordinary failure — no transaction UI, no Retry)', () => {
const send = bench({ promptError: { op: 'send', error: { code: 'agent-busy', message: 'boom', details: { reason: 'boom' } } } })
expect(send.view.container.querySelector('[role="alert"]')?.textContent).toBe('boom (agent-busy)')
expect(send.view.queryByRole('button', { name: 'Retry' })).toBeNull()
})
it('renders the notice strip from the machine notice store', () => {
const { view, shell } = bench()
act(() => { shell.notify('error', '命令失败了') })
expect(view.getByText('命令失败了')).toBeTruthy()
})
it('hero variant adds the hero class and accessory row renders', () => {
const { view } = bench({ variant: 'hero', accessory: <i data-testid="acc" /> })
expect(view.getByTestId('acc')).toBeTruthy()
expect(view.container.querySelector('[class*="hero"]')).not.toBeNull()
})
it('renders overlay anchor and left/right slot items', () => {
const { view } = bench({
overlay: <i data-testid="ov" />,
leftItems: <i data-testid="li" />,
rightItems: <i data-testid="ri" />,
})
expect(view.getByTestId('ov')).toBeTruthy()
expect(view.getByTestId('li')).toBeTruthy()
expect(view.getByTestId('ri')).toBeTruthy()
})
})
describe('command launcher chrome and control seats', () => {
it('renders the command launcher; the Access chip is absent without the permissions projection; plan/model seats render EMPTY without entries (B ruling)', () => {
const { view, slotCalls } = bench()
expect(view.getByLabelText('命令')).toBeTruthy()
// Capability absent (no projection value): the chip renders nothing.
expect(view.queryByLabelText(/^访问模式/)).toBeNull()
// Both seats dispatched, nothing rendered.
expect(slotCalls.map(c => c.key)).toEqual(['conversation.input.plan', 'conversation.input.model'])
expect(view.queryByLabelText('Plan mode')).toBeNull()
expect(view.queryByLabelText('Model')).toBeNull()
})
it('passes the textarea selection to the command menu launcher and reflects its expanded state', () => {
const toggleCommandMenu = vi.fn()
const { view, textarea, menuLauncher } = bench({ draft: 'draft text', toggleCommandMenu })
textarea.setSelectionRange(2, 7)
const launcher = view.getByLabelText('命令')
expect(launcher.getAttribute('aria-expanded')).toBe('false')
fireEvent.click(launcher)
expect(toggleCommandMenu).toHaveBeenCalledExactlyOnceWith({ start: 2, end: 7 })
act(() => { menuLauncher.set('command') })
expect(launcher.getAttribute('aria-expanded')).toBe('true')
})
it('the Access chip renders the projection value and submits a non-Full-access pick directly', async () => {
const command = vi.fn(() => Promise.resolve(true))
const permissions = {
options: [
{ value: 'read-only', name: 'read-only' },
{ value: 'workspace-write', name: 'workspace-write' },
{ value: 'danger-full-access', name: 'danger-full-access' },
],
currentValue: 'read-only',
}
const { view } = bench({ permissions, command })
const trigger = view.getByLabelText(/^访问模式/) as HTMLButtonElement
// Title-case display is presentation only; the menu ids stay machine names.
expect(trigger.textContent).toBe('Read Only')
fireEvent.click(trigger)
const items = view.getAllByRole('menuitem')
expect(items.map(o => o.textContent)).toEqual(['Read Only', 'Workspace Write', 'Full access'])
fireEvent.click(items[1]!)
// Optimistic pick + disable until admission resolves (command stub resolves true).
const busy = view.getByLabelText(/^访问模式/) as HTMLButtonElement
expect(busy.textContent).toBe('Workspace Write')
expect(busy.disabled).toBe(true)
expect(command).toHaveBeenCalledWith('/permission workspace-write')
await act(async () => {})
expect((view.getByLabelText(/^访问模式/) as HTMLButtonElement).disabled).toBe(false)
})
it('requires explicit risk acknowledgement before submitting Full access', async () => {
const command = vi.fn(() => Promise.resolve(true))
const permissions = {
options: [
{ value: 'workspace-write', name: 'workspace-write' },
{ value: 'danger-full-access', name: 'danger-full-access' },
],
currentValue: 'workspace-write',
}
const { view } = bench({ permissions, command })
fireEvent.click(view.getByLabelText(/^访问模式/))
fireEvent.click(view.getByRole('menuitem', { name: 'Full access' }))
expect(command).not.toHaveBeenCalled()
expect(view.getByRole('dialog', { name: '确认启用 Full access' })).toBeTruthy()
const enable = view.getByRole('button', { name: '启用 Full access' }) as HTMLButtonElement
expect(enable.disabled).toBe(true)
fireEvent.click(view.getByRole('checkbox', { name: '我已了解风险,并愿意继续' }))
expect(enable.disabled).toBe(false)
fireEvent.click(enable)
expect(command).toHaveBeenCalledOnce()
expect(command).toHaveBeenCalledWith('/permission danger-full-access')
expect(view.queryByRole('dialog')).toBeNull()
expect((view.getByLabelText(/^访问模式/) as HTMLButtonElement).textContent).toBe('Full access')
await act(async () => {})
})
it('cancels a Full access selection without changing permission and resets acknowledgement', () => {
const command = vi.fn(() => Promise.resolve(true))
const permissions = {
options: [
{ value: 'workspace-write', name: 'workspace-write' },
{ value: 'danger-full-access', name: 'danger-full-access' },
],
currentValue: 'workspace-write',
}
const { view } = bench({ permissions, command })
const openConfirmation = () => {
fireEvent.click(view.getByLabelText(/^访问模式/))
fireEvent.click(view.getByRole('menuitem', { name: 'Full access' }))
}
openConfirmation()
fireEvent.click(view.getByRole('checkbox'))
fireEvent.click(view.getByRole('button', { name: '取消' }))
expect(command).not.toHaveBeenCalled()
expect((view.getByLabelText(/^访问模式/) as HTMLButtonElement).textContent).toBe('Workspace Write')
openConfirmation()
expect((view.getByRole('checkbox') as HTMLInputElement).checked).toBe(false)
expect((view.getByRole('button', { name: '启用 Full access' }) as HTMLButtonElement).disabled).toBe(true)
})
it('revokes an open Full access confirmation when the task locks', () => {
const command = vi.fn(() => Promise.resolve(true))
const permissions = {
options: [
{ value: 'workspace-write', name: 'workspace-write' },
{ value: 'danger-full-access', name: 'danger-full-access' },
],
currentValue: 'workspace-write',
}
const { view, session } = bench({ permissions, command })
fireEvent.click(view.getByLabelText(/^访问模式/))
fireEvent.click(view.getByRole('menuitem', { name: 'Full access' }))
fireEvent.click(view.getByRole('checkbox'))
act(() => { session.set(snapshotOf({ removed: true })) })
expect(view.queryByRole('dialog')).toBeNull()
expect(command).not.toHaveBeenCalled()
})
it('resets an open Full access confirmation when switching tasks', () => {
const command = vi.fn(() => Promise.resolve(true))
const permissions = {
options: [
{ value: 'workspace-write', name: 'workspace-write' },
{ value: 'danger-full-access', name: 'danger-full-access' },
],
currentValue: 'workspace-write',
}
const { view, props } = bench({ permissions, command })
fireEvent.click(view.getByLabelText(/^访问模式/))
fireEvent.click(view.getByRole('menuitem', { name: 'Full access' }))
fireEvent.click(view.getByRole('checkbox'))
view.rerender(<InputBar {...props} sessionId={'s2' as SessionId} />)
expect(view.queryByRole('dialog')).toBeNull()
expect(command).not.toHaveBeenCalled()
})
it('a registered entry fills its seat and receives the locked owner prop', () => {
const { view, slotCalls } = bench({
disabled: true,
planEntry: <i data-testid="plan-entry" />,
modelEntry: <i data-testid="model-entry" />,
})
expect(view.getByTestId('plan-entry')).toBeTruthy()
expect(view.getByTestId('model-entry')).toBeTruthy()
// The bar hands its chrome disable state to the filling entry.
expect(slotCalls.every(c => (c.owner as { locked: boolean }).locked)).toBe(true)
cleanup()
const live = bench({ running: true })
expect(live.slotCalls.every(c => !(c.owner as { locked: boolean }).locked)).toBe(true)
})
it('disabled locks the Access chip and command launcher (running does not)', () => {
const permissions = { options: [{ value: 'workspace-write', name: 'workspace-write' }], currentValue: 'workspace-write' }
const { view } = bench({ disabled: true, permissions })
expect((view.getByLabelText('命令') as HTMLButtonElement).disabled).toBe(true)
expect((view.getByLabelText(/^访问模式/) as HTMLButtonElement).disabled).toBe(true)
cleanup()
const live = bench({ running: true, permissions })
expect((live.view.getByLabelText(/^访问模式/) as HTMLButtonElement).disabled).toBe(false)
})
})