refactor: apply repository naming contract
Apply the accepted pre-release package, service, type, directory, and role renames as one repository-wide change.
This commit is contained in:
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* ui-commands browser half on a real cordis Context with fake slash/slots
|
||||
* faces and real session scopes: the plugin body mounts CommandUiRuntime as
|
||||
* `command`, the popupSelect shell registers into conversation.input.overlay
|
||||
* through slot declaration injection with a per-session inject (sessionId →
|
||||
* scope → popupFor; unknown id fails loud), both fold up on fiber disposal
|
||||
* (HMR safety), and the service satisfies the frozen CommandUiContract.
|
||||
*/
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createScope, scopeOf, SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { InputTriggerSource } from '@deepseek-ai/dsh-client-ui-input-trigger/client'
|
||||
import type { CommandUiContract } from '../src/client/contract.ts'
|
||||
import type { PopupSelectInjected } from '../src/client/PopupSelectView.tsx'
|
||||
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { apply, CommandUiRuntime, inject } from '../src/client/index.ts'
|
||||
|
||||
const sid = (k: string): SessionId => k as SessionId
|
||||
|
||||
async function bench() {
|
||||
const ctx = new Context()
|
||||
const sources = new Map<string, InputTriggerSource>()
|
||||
ctx.provide('inputTriggers', {
|
||||
registerSource(src: InputTriggerSource) {
|
||||
sources.set(`${src.trigger} ${src.name}`, src)
|
||||
return () => { sources.delete(`${src.trigger} ${src.name}`) }
|
||||
},
|
||||
})
|
||||
const scopes = new Map<SessionId, Context>()
|
||||
ctx.provide('sessions', {
|
||||
scope: (id: SessionId) => scopes.get(id),
|
||||
scopeOf: (c: Context) => scopeOf(c),
|
||||
})
|
||||
const commandsRemote = { list: () => Promise.resolve([]) }
|
||||
// The service subscribes its cache-invalidation events on construction, so
|
||||
// the Remote face needs `$on` even where this spec dispatches none.
|
||||
ctx.provide('remote', { commands: commandsRemote, $on: () => () => {} })
|
||||
ctx.provide('remote.commands', commandsRemote)
|
||||
await ctx.plugin(SlotRegistry).await()
|
||||
ctx.slots.register({
|
||||
name: 'root', children: { 'conversation.input.overlay': { kind: 'list', scope: 'session' } },
|
||||
} as never, (() => null) as never)
|
||||
ctx.provide('locale', new LocaleRuntime(ctx))
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
const mint = (key: string) => {
|
||||
const handle = createScope(ctx, sid(key))
|
||||
scopes.set(sid(key), handle.ctx)
|
||||
return handle
|
||||
}
|
||||
return { ctx, fiber, sources, slots: ctx.slots, mint }
|
||||
}
|
||||
|
||||
describe('apply', () => {
|
||||
it('declares the services it binds', () => {
|
||||
expect(inject).toEqual(['inputTriggers', 'sessions', 'remote', 'remote.commands', 'locale'])
|
||||
})
|
||||
|
||||
it('mounts ctx.commandUi, registers the source and the overlay entry, and folds up on disposal', async () => {
|
||||
const { ctx, fiber, sources, slots } = await bench()
|
||||
const command = ctx.get('commandUi')
|
||||
expect(command).toBeInstanceOf(CommandUiRuntime)
|
||||
// Frozen-contract conformance (compile-time check rides the assignment).
|
||||
const contract: CommandUiContract = command as CommandUiRuntime
|
||||
expect(typeof contract.register).toBe('function')
|
||||
expect(typeof contract.popupFor).toBe('function')
|
||||
expect([...sources.keys()]).toEqual(['/ command'])
|
||||
expect(slots.entries('conversation.input.overlay').map(entry => entry.options.id)).toEqual(['command-popup'])
|
||||
await fiber.dispose()
|
||||
expect(sources.size).toBe(0)
|
||||
expect(slots.entries('conversation.input.overlay')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('the overlay inject resolves the per-session popup controller by sessionId and fails loud on an unknown id', async () => {
|
||||
const { ctx, slots, mint } = await bench()
|
||||
const command = ctx.get('commandUi') as CommandUiRuntime
|
||||
const scope = mint('s1')
|
||||
const entry = slots.entries('conversation.input.overlay')[0]!
|
||||
const injectEntry = entry.inject as unknown as (sessionId: SessionId) => PopupSelectInjected
|
||||
expect(injectEntry(sid('s1')).popup).toBe(command.popupFor(scope.ctx))
|
||||
expect(() => injectEntry(sid('ghost'))).toThrow(/resolved no scope/)
|
||||
})
|
||||
})
|
||||
293
packages/client/ui-commands/tests/directory.client.spec.ts
Normal file
293
packages/client/ui-commands/tests/directory.client.spec.ts
Normal file
@@ -0,0 +1,293 @@
|
||||
/**
|
||||
* CommandDirectory unit tests over the session-key axis: per-key status
|
||||
* transitions and epoch guard, key isolation across sessions, soft
|
||||
* invalidation (invalidateAll), the reconnect hard reset (resetConnected:
|
||||
* every entry drops its snapshot and prewarms), the warm hook's cold/failed
|
||||
* gate, and the per-key ensureReady strong-wait policy.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import type { CommandDescriptor } from '../src/client/directory.ts'
|
||||
import { CommandDirectory } from '../src/client/directory.ts'
|
||||
|
||||
const sid = (k: string): SessionId => k as SessionId
|
||||
const S1 = sid('s1')
|
||||
const S2 = sid('s2')
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (reason?: unknown) => void
|
||||
const promise = new Promise<T>((res, rej) => { resolve = res; reject = rej })
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
const CMDS: CommandDescriptor[] = [
|
||||
{ name: 'plan', description: 'plan mode' },
|
||||
{ name: 'goal', description: 'set goal', input: { hint: 'goal text' } },
|
||||
]
|
||||
|
||||
const S2_CMDS: CommandDescriptor[] = [
|
||||
...CMDS,
|
||||
{ name: 'attach', description: 'attach a file', input: { hint: 'path' } },
|
||||
]
|
||||
|
||||
/** Directory over per-key pull queues: each fetch appends a hand-settled deferred. */
|
||||
function bench() {
|
||||
const pulls = new Map<SessionId, Array<ReturnType<typeof deferred<readonly CommandDescriptor[]>>>>()
|
||||
const calls: SessionId[] = []
|
||||
const dir = new CommandDirectory((key) => {
|
||||
calls.push(key)
|
||||
const d = deferred<readonly CommandDescriptor[]>()
|
||||
const queue = pulls.get(key) ?? []
|
||||
queue.push(d)
|
||||
pulls.set(key, queue)
|
||||
return d.promise
|
||||
})
|
||||
const pull = (key: SessionId, i: number) => {
|
||||
const d = pulls.get(key)?.[i]
|
||||
if (d === undefined) throw new Error(`no pull #${i} for ${key}`)
|
||||
return d
|
||||
}
|
||||
return { dir, pull, calls, countOf: (key: SessionId) => pulls.get(key)?.length ?? 0 }
|
||||
}
|
||||
|
||||
describe('status and resolve (per key)', () => {
|
||||
it('starts cold and resolves nothing', () => {
|
||||
const { dir } = bench()
|
||||
expect(dir.status(S1)).toBe('cold')
|
||||
expect(dir.resolve(S1, 'plan')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('serves exact-name lookups once ready, undefined for unknown names', async () => {
|
||||
const { dir, pull } = bench()
|
||||
const refreshed = dir.refresh(S1)
|
||||
expect(dir.status(S1)).toBe('pending')
|
||||
pull(S1, 0).resolve(CMDS)
|
||||
await refreshed
|
||||
expect(dir.status(S1)).toBe('ready')
|
||||
expect(dir.resolve(S1, 'goal')).toEqual(CMDS[1])
|
||||
expect(dir.resolve(S1, 'nope')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('drops the snapshot and records failure on a failed pull', async () => {
|
||||
const { dir, pull } = bench()
|
||||
const refreshed = dir.refresh(S1)
|
||||
pull(S1, 0).reject(new Error('boom'))
|
||||
await refreshed
|
||||
expect(dir.status(S1)).toBe('failed')
|
||||
expect(dir.resolve(S1, 'plan')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keys are isolated: one session catalog landing leaves another cold', async () => {
|
||||
const { dir, pull } = bench()
|
||||
const refreshed = dir.refresh(S1)
|
||||
pull(S1, 0).resolve(CMDS)
|
||||
await refreshed
|
||||
expect(dir.status(S2)).toBe('cold')
|
||||
expect(dir.resolve(S2, 'plan')).toBeUndefined()
|
||||
|
||||
const other = dir.refresh(S2)
|
||||
pull(S2, 0).resolve(S2_CMDS)
|
||||
await other
|
||||
expect(dir.resolve(S2, 'attach')).toBeDefined()
|
||||
expect(dir.resolve(S1, 'attach')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('epoch guard (per key)', () => {
|
||||
it('a superseded pull cannot overwrite the newer one (old resolves after new)', async () => {
|
||||
const { dir, pull } = bench()
|
||||
const first = dir.refresh(S1)
|
||||
const second = dir.refresh(S1)
|
||||
pull(S1, 1).resolve(CMDS)
|
||||
await second
|
||||
expect(dir.resolve(S1, 'plan')).toBeDefined()
|
||||
pull(S1, 0).resolve([{ name: 'stale', description: 'old world' }])
|
||||
await first
|
||||
expect(dir.resolve(S1, 'stale')).toBeUndefined()
|
||||
expect(dir.resolve(S1, 'plan')).toBeDefined()
|
||||
})
|
||||
|
||||
it('a superseded failure cannot demote the newer success', async () => {
|
||||
const { dir, pull } = bench()
|
||||
const first = dir.refresh(S1)
|
||||
const second = dir.refresh(S1)
|
||||
pull(S1, 1).resolve(CMDS)
|
||||
await second
|
||||
pull(S1, 0).reject(new Error('late failure'))
|
||||
await first
|
||||
expect(dir.status(S1)).toBe('ready')
|
||||
expect(dir.resolve(S1, 'plan')).toBeDefined()
|
||||
})
|
||||
|
||||
it('epochs are per key: one session supersede leaves another session epoch alone', async () => {
|
||||
const { dir, pull } = bench()
|
||||
const one = dir.refresh(S1)
|
||||
void dir.refresh(S2)
|
||||
void dir.refresh(S2) // supersedes the s2 pull only
|
||||
pull(S1, 0).resolve(CMDS)
|
||||
await one
|
||||
expect(dir.status(S1)).toBe('ready')
|
||||
})
|
||||
})
|
||||
|
||||
describe('invalidateAll (commands-changed soft)', () => {
|
||||
it('repulls every touched key in the background while ready snapshots keep serving', async () => {
|
||||
const { dir, pull, countOf } = bench()
|
||||
const a = dir.refresh(S1)
|
||||
const b = dir.refresh(S2)
|
||||
pull(S1, 0).resolve(CMDS)
|
||||
pull(S2, 0).resolve(S2_CMDS)
|
||||
await Promise.all([a, b])
|
||||
|
||||
dir.invalidateAll()
|
||||
expect(countOf(S1)).toBe(2)
|
||||
expect(countOf(S2)).toBe(2)
|
||||
expect(dir.status(S1)).toBe('ready')
|
||||
expect(dir.resolve(S2, 'attach')).toBeDefined()
|
||||
|
||||
pull(S1, 1).resolve([{ name: 'fresh', description: 'new world' }])
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(dir.resolve(S1, 'fresh')).toBeDefined()
|
||||
expect(dir.resolve(S1, 'plan')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('an untouched directory invalidates to nothing (no keys, no pulls)', () => {
|
||||
const { dir, calls } = bench()
|
||||
dir.invalidateAll()
|
||||
expect(calls).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('resetConnected (reconnect hard)', () => {
|
||||
it('every entry drops its snapshot immediately and prewarms', async () => {
|
||||
const { dir, pull, countOf } = bench()
|
||||
const a = dir.refresh(S1)
|
||||
const b = dir.refresh(S2)
|
||||
pull(S1, 0).resolve(CMDS)
|
||||
pull(S2, 0).resolve(S2_CMDS)
|
||||
await Promise.all([a, b])
|
||||
|
||||
dir.resetConnected()
|
||||
// Hard: the agent world may have changed shape across the generation.
|
||||
expect(dir.status(S1)).toBe('pending')
|
||||
expect(dir.resolve(S1, 'plan')).toBeUndefined()
|
||||
expect(dir.status(S2)).toBe('pending')
|
||||
expect(dir.resolve(S2, 'attach')).toBeUndefined()
|
||||
expect(countOf(S1)).toBe(2)
|
||||
expect(countOf(S2)).toBe(2)
|
||||
|
||||
pull(S1, 1).resolve(CMDS)
|
||||
pull(S2, 1).resolve(S2_CMDS)
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(dir.status(S1)).toBe('ready')
|
||||
expect(dir.resolve(S2, 'attach')).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('warm', () => {
|
||||
it('launches a pull from cold, again after failure, and never over pending/ready', async () => {
|
||||
const { dir, pull, countOf } = bench()
|
||||
dir.warm(S1)
|
||||
expect(countOf(S1)).toBe(1)
|
||||
dir.warm(S1) // pending → no second pull
|
||||
expect(countOf(S1)).toBe(1)
|
||||
|
||||
pull(S1, 0).reject(new Error('boom'))
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(dir.status(S1)).toBe('failed')
|
||||
dir.warm(S1) // failed → retry
|
||||
expect(countOf(S1)).toBe(2)
|
||||
|
||||
pull(S1, 1).resolve(CMDS)
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
dir.warm(S1) // ready → no-op
|
||||
expect(countOf(S1)).toBe(2)
|
||||
})
|
||||
|
||||
it('warms keys independently', () => {
|
||||
const { dir, countOf } = bench()
|
||||
dir.warm(S2)
|
||||
expect(countOf(S2)).toBe(1)
|
||||
expect(countOf(S1)).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ensureReady (per key)', () => {
|
||||
const signal = () => new AbortController().signal
|
||||
|
||||
it('returns the hot snapshot at once when ready', async () => {
|
||||
const { dir, pull, countOf } = bench()
|
||||
const warm = dir.refresh(S1)
|
||||
pull(S1, 0).resolve(CMDS)
|
||||
await warm
|
||||
await expect(dir.ensureReady(S1, signal())).resolves.toEqual(CMDS)
|
||||
expect(countOf(S1)).toBe(1)
|
||||
})
|
||||
|
||||
it('launches a pull from cold and resolves on arrival, without touching other keys', async () => {
|
||||
const { dir, pull, countOf } = bench()
|
||||
const wait = dir.ensureReady(S2, signal())
|
||||
expect(dir.status(S2)).toBe('pending')
|
||||
pull(S2, 0).resolve(S2_CMDS)
|
||||
await expect(wait).resolves.toEqual(S2_CMDS)
|
||||
expect(countOf(S1)).toBe(0)
|
||||
})
|
||||
|
||||
it('joins a flying pull instead of starting a second one', async () => {
|
||||
const { dir, pull, countOf } = bench()
|
||||
void dir.refresh(S1)
|
||||
const wait = dir.ensureReady(S1, signal())
|
||||
expect(countOf(S1)).toBe(1)
|
||||
pull(S1, 0).resolve(CMDS)
|
||||
await expect(wait).resolves.toEqual(CMDS)
|
||||
})
|
||||
|
||||
it('rejects when the awaited pull fails (no silent downgrade)', async () => {
|
||||
const { dir, pull } = bench()
|
||||
const wait = dir.ensureReady(S1, signal())
|
||||
pull(S1, 0).reject(new Error('warmup boom'))
|
||||
await expect(wait).rejects.toThrow('command directory warmup failed: warmup boom')
|
||||
})
|
||||
|
||||
it('retries from failed state with a fresh pull', async () => {
|
||||
const { dir, pull } = bench()
|
||||
const first = dir.ensureReady(S1, signal())
|
||||
pull(S1, 0).reject(new Error('boom'))
|
||||
await expect(first).rejects.toThrow()
|
||||
const second = dir.ensureReady(S1, signal())
|
||||
pull(S1, 1).resolve(CMDS)
|
||||
await expect(second).resolves.toEqual(CMDS)
|
||||
})
|
||||
|
||||
it('rejects on abort while waiting', async () => {
|
||||
const { dir } = bench()
|
||||
const ac = new AbortController()
|
||||
const wait = dir.ensureReady(S1, ac.signal)
|
||||
ac.abort(new Error('attempt superseded'))
|
||||
await expect(wait).rejects.toThrow('attempt superseded')
|
||||
})
|
||||
|
||||
it('rejects immediately on an already-aborted signal', async () => {
|
||||
const { dir, pull } = bench()
|
||||
const warm = dir.refresh(S1)
|
||||
pull(S1, 0).reject(new Error('irrelevant'))
|
||||
await warm
|
||||
const ac = new AbortController()
|
||||
ac.abort() // bare abort: the DOMException reason is itself an Error and travels as-is
|
||||
await expect(dir.ensureReady(S1, ac.signal)).rejects.toThrow(/aborted/)
|
||||
})
|
||||
|
||||
it('keeps waiting across a superseded pull and settles on the winner', async () => {
|
||||
const { dir, pull } = bench()
|
||||
const wait = dir.ensureReady(S1, signal())
|
||||
void dir.refresh(S1) // supersedes pull #0 with pull #1
|
||||
pull(S1, 0).resolve([{ name: 'stale', description: 'loser' }])
|
||||
pull(S1, 1).resolve(CMDS)
|
||||
await expect(wait).resolves.toEqual(CMDS)
|
||||
})
|
||||
})
|
||||
254
packages/client/ui-commands/tests/popup-view.client.spec.tsx
Normal file
254
packages/client/ui-commands/tests/popup-view.client.spec.tsx
Normal file
@@ -0,0 +1,254 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* PopupSelectView interaction spec: the search input takes
|
||||
* focus on open and plain typing filters locally, ↑↓ move the filtered
|
||||
* highlight while ←→ stay native to the input, Enter selects single-flight,
|
||||
* Escape dismisses back through focusComposer, outside pointerdown dismisses
|
||||
* plainly, the submitting/failed states render pending text and a working
|
||||
* retry button, the highlighted row scrolls into view, and the card height
|
||||
* clamps to the space above the composer.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import type { SelectOption } from '../src/client/contract.ts'
|
||||
import type { PopupSpec, TokenSegment } from '../src/client/popup.ts'
|
||||
import { PopupSelectController } from '../src/client/popup.ts'
|
||||
import { PopupSelectView } from '../src/client/PopupSelectView.tsx'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
|
||||
import { zh } from '../src/client/locales.ts'
|
||||
|
||||
// The framework-injected t seat, stubbed over the zh dictionaries (the default locale).
|
||||
const t: Parameters<typeof PopupSelectView>[0]['t'] = makeTranslate(zh, commonZh)
|
||||
|
||||
// jsdom has no scrollIntoView; the view calls it on the highlighted row.
|
||||
const scrollIntoView = vi.fn()
|
||||
beforeEach(() => {
|
||||
Element.prototype.scrollIntoView = scrollIntoView
|
||||
scrollIntoView.mockClear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
const OPTIONS: SelectOption[] = [
|
||||
{ id: 'dark', label: 'Dark' },
|
||||
{ id: 'light', label: 'Light', active: true },
|
||||
{ id: 'sepia', label: 'Sepia', detail: 'warm' },
|
||||
]
|
||||
const GATED: SelectOption = {
|
||||
id: 'full',
|
||||
label: 'Full access',
|
||||
confirmation: {
|
||||
title: 'Enable Full access?',
|
||||
description: 'Sensitive operations.',
|
||||
acknowledgeLabel: 'I understand the risks',
|
||||
cancelLabel: 'Cancel',
|
||||
confirmLabel: 'Enable Full access',
|
||||
},
|
||||
}
|
||||
|
||||
const SEGMENT: TokenSegment = { via: 'enter', token: '/theme' }
|
||||
|
||||
function spec(overrides: Partial<PopupSpec<string>> = {}): PopupSpec<string> {
|
||||
return {
|
||||
options: () => Promise.resolve(OPTIONS),
|
||||
onSelect: () => undefined,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
async function mountOpen(overrides: Partial<PopupSpec<string>> = {}, consumeResult = true) {
|
||||
const consume = vi.fn((_segment: TokenSegment) => consumeResult)
|
||||
const focusComposer = vi.fn()
|
||||
const popup = new PopupSelectController<string>({ consume, focusComposer })
|
||||
const view = render(<PopupSelectView popup={popup} t={t} />)
|
||||
await act(async () => {
|
||||
popup.open('theme', spec(overrides), 'ctx-A', SEGMENT)
|
||||
await Promise.resolve()
|
||||
})
|
||||
return { popup, view, consume, focusComposer, search: screen.getByRole('textbox', { name: '筛选选项' }) }
|
||||
}
|
||||
|
||||
function rowLabels(): string[] {
|
||||
return screen.getAllByRole('option').map(o => o.querySelector('span')!.textContent)
|
||||
}
|
||||
|
||||
describe('PopupSelectView', () => {
|
||||
it('renders null while closed, opens with focus in the search input', async () => {
|
||||
const popup = new PopupSelectController<string>({ consume: () => true, focusComposer: () => {} })
|
||||
const view = render(<PopupSelectView popup={popup} t={t} />)
|
||||
expect(view.container.childElementCount).toBe(0)
|
||||
await act(async () => {
|
||||
popup.open('theme', spec(), 'ctx-A', SEGMENT)
|
||||
await Promise.resolve()
|
||||
})
|
||||
const search = screen.getByRole('textbox', { name: '筛选选项' })
|
||||
expect(document.activeElement).toBe(search)
|
||||
expect(rowLabels()).toEqual(['Dark', 'Light', 'Sepia'])
|
||||
})
|
||||
|
||||
it('typing filters rows locally and rebases the highlight', async () => {
|
||||
const options = vi.fn(() => Promise.resolve(OPTIONS))
|
||||
const { search } = await mountOpen({ options })
|
||||
act(() => { fireEvent.change(search, { target: { value: 'li' } }) })
|
||||
expect(rowLabels()).toEqual(['Light'])
|
||||
expect(screen.getByRole('option').getAttribute('aria-selected')).toBe('true')
|
||||
expect(options).toHaveBeenCalledTimes(1)
|
||||
act(() => { fireEvent.change(search, { target: { value: 'zzz' } }) })
|
||||
expect(screen.queryByRole('option')).toBeNull()
|
||||
expect(screen.queryByText('无选项')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('ArrowUp/Down move the filtered highlight; ArrowLeft/Right are left to the native caret', async () => {
|
||||
const { search } = await mountOpen()
|
||||
act(() => { fireEvent.keyDown(search, { key: 'ArrowDown' }) })
|
||||
let options = screen.getAllByRole('option')
|
||||
expect(options[1]!.getAttribute('aria-selected')).toBe('true')
|
||||
act(() => { fireEvent.keyDown(search, { key: 'ArrowUp' }) })
|
||||
options = screen.getAllByRole('option')
|
||||
expect(options[0]!.getAttribute('aria-selected')).toBe('true')
|
||||
// fireEvent returns false when preventDefault was called: arrow left/right must NOT be intercepted.
|
||||
expect(fireEvent.keyDown(search, { key: 'ArrowLeft' })).toBe(true)
|
||||
expect(fireEvent.keyDown(search, { key: 'ArrowRight' })).toBe(true)
|
||||
})
|
||||
|
||||
it('scrolls the highlighted row into view when the highlight moves', async () => {
|
||||
const { search } = await mountOpen()
|
||||
scrollIntoView.mockClear()
|
||||
act(() => { fireEvent.keyDown(search, { key: 'ArrowDown' }) })
|
||||
const options = screen.getAllByRole('option')
|
||||
expect(scrollIntoView).toHaveBeenCalledWith({ block: 'nearest' })
|
||||
expect(scrollIntoView.mock.instances.at(-1)).toBe(options[1])
|
||||
})
|
||||
|
||||
it('caps the card height at the design maximum when the composer sits low enough', async () => {
|
||||
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue({ bottom: 800 } as DOMRect)
|
||||
await mountOpen()
|
||||
expect(screen.getByLabelText('/theme 选项').style.maxHeight).toBe('320px')
|
||||
})
|
||||
|
||||
it('clamps the card height to the space above the composer minus the safe margin', async () => {
|
||||
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue({ bottom: 200 } as DOMRect)
|
||||
await mountOpen()
|
||||
expect(screen.getByLabelText('/theme 选项').style.maxHeight).toBe('188px')
|
||||
})
|
||||
|
||||
it('Enter selects the highlighted row: onSelect, consume, close, focusComposer', async () => {
|
||||
const seen: Array<{ option: SelectOption; context: string }> = []
|
||||
const { view, search, consume, focusComposer } = await mountOpen({
|
||||
onSelect: (option, context) => { seen.push({ option, context }) },
|
||||
})
|
||||
act(() => { fireEvent.keyDown(search, { key: 'ArrowDown' }) })
|
||||
await act(async () => { fireEvent.keyDown(search, { key: 'Enter' }) })
|
||||
expect(seen).toEqual([{ option: OPTIONS[1], context: 'ctx-A' }])
|
||||
expect(consume).toHaveBeenCalledExactlyOnceWith(SEGMENT)
|
||||
expect(focusComposer).toHaveBeenCalledTimes(1)
|
||||
expect(view.container.childElementCount).toBe(0)
|
||||
})
|
||||
|
||||
it('click selects a row; mouseenter moves the highlight', async () => {
|
||||
const seen: SelectOption[] = []
|
||||
const { view } = await mountOpen({ onSelect: (option) => { seen.push(option) } })
|
||||
const options = screen.getAllByRole('option')
|
||||
act(() => { fireEvent.mouseEnter(options[2]!) })
|
||||
expect(screen.getAllByRole('option')[2]!.getAttribute('aria-selected')).toBe('true')
|
||||
await act(async () => { fireEvent.click(options[2]!) })
|
||||
expect(seen).toEqual([OPTIONS[2]])
|
||||
expect(view.container.childElementCount).toBe(0)
|
||||
})
|
||||
|
||||
it('renders a gated option as an in-page modal and requires the checkbox before onSelect', async () => {
|
||||
const onSelect = vi.fn()
|
||||
const { popup, consume } = await mountOpen({
|
||||
options: () => Promise.resolve([GATED]),
|
||||
onSelect,
|
||||
})
|
||||
await act(async () => { fireEvent.click(screen.getByRole('option', { name: 'Full access' })) })
|
||||
expect(screen.queryByLabelText('/theme 选项')).toBeNull()
|
||||
expect(screen.getByRole('dialog', { name: 'Enable Full access?' })).toBeTruthy()
|
||||
const enable = screen.getByRole('button', { name: 'Enable Full access' }) as HTMLButtonElement
|
||||
expect(enable.disabled).toBe(true)
|
||||
expect(onSelect).not.toHaveBeenCalled()
|
||||
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: 'I understand the risks' }))
|
||||
expect(enable.disabled).toBe(false)
|
||||
await act(async () => { fireEvent.click(enable) })
|
||||
expect(onSelect).toHaveBeenCalledExactlyOnceWith(GATED, 'ctx-A')
|
||||
expect(consume).toHaveBeenCalledExactlyOnceWith(SEGMENT)
|
||||
expect(popup.state.getSnapshot().open).toBe(false)
|
||||
})
|
||||
|
||||
it('canceling a gated option returns to the picker with acknowledgement reset', async () => {
|
||||
await mountOpen({ options: () => Promise.resolve([GATED]) })
|
||||
await act(async () => { fireEvent.click(screen.getByRole('option', { name: 'Full access' })) })
|
||||
fireEvent.click(screen.getByRole('checkbox'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||
expect(screen.getByLabelText('/theme 选项')).toBeTruthy()
|
||||
await act(async () => { fireEvent.click(screen.getByRole('option', { name: 'Full access' })) })
|
||||
expect(screen.getByRole<HTMLInputElement>('checkbox').checked).toBe(false)
|
||||
})
|
||||
|
||||
it('submitting shows pending, locks the search input, and further Enter/click no-op', async () => {
|
||||
let release!: () => void
|
||||
const onSelect = vi.fn(() => new Promise<void>((resolve) => { release = resolve }))
|
||||
const { search, consume } = await mountOpen({ onSelect })
|
||||
await act(async () => { fireEvent.keyDown(search, { key: 'Enter' }) })
|
||||
expect(screen.queryByText('正在应用…')).not.toBeNull()
|
||||
expect((search as HTMLInputElement).readOnly).toBe(true)
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(search, { key: 'Enter' })
|
||||
fireEvent.click(screen.getAllByRole('option')[1]!)
|
||||
})
|
||||
expect(onSelect).toHaveBeenCalledTimes(1)
|
||||
await act(async () => {
|
||||
release()
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(consume).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('a failed options load shows the error with a retry button that reloads', async () => {
|
||||
let attempts = 0
|
||||
await mountOpen({
|
||||
options: () => {
|
||||
attempts += 1
|
||||
return attempts === 1 ? Promise.reject(new Error('directory down')) : Promise.resolve(OPTIONS)
|
||||
},
|
||||
})
|
||||
expect(screen.getByRole('alert').textContent).toContain('directory down')
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: '重试' }))
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(attempts).toBe(2)
|
||||
expect(rowLabels()).toEqual(['Dark', 'Light', 'Sepia'])
|
||||
})
|
||||
|
||||
it('an onSelect failure keeps the shell open with the error strip and no retry button (re-select is the retry)', async () => {
|
||||
const { search, consume } = await mountOpen({ onSelect: () => Promise.reject(new Error('host rejected')) })
|
||||
await act(async () => { fireEvent.keyDown(search, { key: 'Enter' }) })
|
||||
expect(screen.getByRole('alert').textContent).toContain('host rejected')
|
||||
expect(screen.queryByRole('button', { name: '重试' })).toBeNull()
|
||||
expect(consume).not.toHaveBeenCalled()
|
||||
expect(screen.getAllByRole('option').length).toBe(3)
|
||||
})
|
||||
|
||||
it('Escape dismisses and restores composer focus', async () => {
|
||||
const { view, search, focusComposer } = await mountOpen()
|
||||
act(() => { fireEvent.keyDown(search, { key: 'Escape' }) })
|
||||
expect(view.container.childElementCount).toBe(0)
|
||||
expect(focusComposer).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('an outside pointerdown dismisses without focusComposer; an inside one does not dismiss', async () => {
|
||||
const { view, focusComposer } = await mountOpen()
|
||||
act(() => { fireEvent.pointerDown(screen.getAllByRole('option')[0]!) })
|
||||
expect(view.container.childElementCount).not.toBe(0)
|
||||
act(() => { fireEvent.pointerDown(document.body) })
|
||||
expect(view.container.childElementCount).toBe(0)
|
||||
expect(focusComposer).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
399
packages/client/ui-commands/tests/popup.client.spec.ts
Normal file
399
packages/client/ui-commands/tests/popup.client.spec.ts
Normal file
@@ -0,0 +1,399 @@
|
||||
/**
|
||||
* PopupSelectController behavior: one options load per
|
||||
* open with local search filtering, filtered highlight movement,
|
||||
* single-flight select with open-time context, consume-on-success (CAS miss
|
||||
* benign), failure-keeps-open retry semantics for both options and onSelect,
|
||||
* and binding-identity revocation of late settlements after
|
||||
* dismiss/reopen/dispose.
|
||||
*/
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { SelectOption } from '../src/client/contract.ts'
|
||||
import type { PopupSpec, TokenSegment } from '../src/client/popup.ts'
|
||||
import { filterOptions, PopupSelectController } from '../src/client/popup.ts'
|
||||
|
||||
interface Ctx { readonly session: string }
|
||||
const CTX_A: Ctx = { session: 'A' }
|
||||
|
||||
const OPTIONS: SelectOption[] = [
|
||||
{ id: 'dark', label: 'Dark' },
|
||||
{ id: 'light', label: 'Light', active: true },
|
||||
{ id: 'sepia', label: 'Sepia', detail: 'warm' },
|
||||
]
|
||||
const GATED: SelectOption = {
|
||||
id: 'full',
|
||||
label: 'Full access',
|
||||
confirmation: {
|
||||
title: 'Enable Full access?',
|
||||
description: 'Sensitive operations.',
|
||||
acknowledgeLabel: 'I understand',
|
||||
cancelLabel: 'Cancel',
|
||||
confirmLabel: 'Enable Full access',
|
||||
},
|
||||
}
|
||||
|
||||
const SEGMENT: TokenSegment = { via: 'enter', token: '/theme' }
|
||||
|
||||
function spec(overrides: Partial<PopupSpec<Ctx>> = {}): PopupSpec<Ctx> {
|
||||
return {
|
||||
options: () => Promise.resolve(OPTIONS),
|
||||
onSelect: () => undefined,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
/** Fake session wiring: records consume/focus calls; consume answer is settable per test. */
|
||||
function makeDeps(consumeResult = true) {
|
||||
const consume = vi.fn((_segment: TokenSegment) => consumeResult)
|
||||
const focusComposer = vi.fn()
|
||||
return { consume, focusComposer }
|
||||
}
|
||||
|
||||
async function readyPopup(overrides: Partial<PopupSpec<Ctx>> = {}, deps = makeDeps()) {
|
||||
const popup = new PopupSelectController<Ctx>(deps)
|
||||
popup.open('theme', spec(overrides), CTX_A, SEGMENT)
|
||||
await Promise.resolve()
|
||||
return { popup, deps }
|
||||
}
|
||||
|
||||
describe('filterOptions', () => {
|
||||
it('matches case-insensitively over label and detail; blank keeps all', () => {
|
||||
expect(filterOptions(OPTIONS, '')).toBe(OPTIONS)
|
||||
expect(filterOptions(OPTIONS, ' ')).toBe(OPTIONS)
|
||||
expect(filterOptions(OPTIONS, 'DARK')).toEqual([OPTIONS[0]])
|
||||
expect(filterOptions(OPTIONS, 'warm')).toEqual([OPTIONS[2]])
|
||||
expect(filterOptions(OPTIONS, 'nope')).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('open and options load', () => {
|
||||
it('publishes pending immediately, ready when options land', async () => {
|
||||
const popup = new PopupSelectController<Ctx>(makeDeps())
|
||||
let release!: (options: readonly SelectOption[]) => void
|
||||
popup.open('theme', spec({ options: () => new Promise((resolve) => { release = resolve }) }), CTX_A, SEGMENT)
|
||||
expect(popup.state.getSnapshot()).toMatchObject({ open: true, command: 'theme', status: 'pending', search: '', submitting: false, error: null })
|
||||
release(OPTIONS)
|
||||
await Promise.resolve()
|
||||
expect(popup.state.getSnapshot()).toMatchObject({ status: 'ready', options: OPTIONS, active: 0 })
|
||||
})
|
||||
|
||||
it('loads options exactly once: search filters locally without re-querying the provider', async () => {
|
||||
const options = vi.fn(() => Promise.resolve(OPTIONS))
|
||||
const { popup } = await readyPopup({ options })
|
||||
popup.setSearch('li')
|
||||
popup.setSearch('light')
|
||||
const s = popup.state.getSnapshot()
|
||||
expect(options).toHaveBeenCalledTimes(1)
|
||||
expect(s.options).toEqual(OPTIONS) // original array retained; filtering is view-side
|
||||
expect(s.search).toBe('light')
|
||||
expect(filterOptions(s.options, s.search)).toEqual([OPTIONS[1]])
|
||||
})
|
||||
|
||||
it('a reopen aborts the old load and drops its late arrival', async () => {
|
||||
const popup = new PopupSelectController<Ctx>(makeDeps())
|
||||
let firstSignal!: AbortSignal
|
||||
let releaseFirst!: (options: readonly SelectOption[]) => void
|
||||
popup.open('alpha', spec({
|
||||
options: (_ctx, signal) => {
|
||||
firstSignal = signal
|
||||
return new Promise((resolve) => { releaseFirst = resolve })
|
||||
},
|
||||
}), CTX_A, SEGMENT)
|
||||
popup.open('beta', spec(), CTX_A, SEGMENT)
|
||||
expect(firstSignal.aborted).toBe(true)
|
||||
releaseFirst([{ id: 'stale', label: 'stale' }])
|
||||
await Promise.resolve()
|
||||
const s = popup.state.getSnapshot()
|
||||
expect(s.command).toBe('beta')
|
||||
expect(s.options).toEqual(OPTIONS)
|
||||
})
|
||||
|
||||
it('dispose aborts the flying load, clears state, and drops the late arrival', async () => {
|
||||
const popup = new PopupSelectController<Ctx>(makeDeps())
|
||||
let signal!: AbortSignal
|
||||
let release!: (options: readonly SelectOption[]) => void
|
||||
popup.open('theme', spec({
|
||||
options: (_ctx, s) => {
|
||||
signal = s
|
||||
return new Promise((resolve) => { release = resolve })
|
||||
},
|
||||
}), CTX_A, SEGMENT)
|
||||
popup.dispose()
|
||||
expect(signal.aborted).toBe(true)
|
||||
expect(popup.state.getSnapshot().open).toBe(false)
|
||||
release(OPTIONS)
|
||||
await Promise.resolve()
|
||||
expect(popup.state.getSnapshot().open).toBe(false)
|
||||
})
|
||||
|
||||
it('an options failure keeps the shell open with search retained, surfaces the error, and retry reloads', async () => {
|
||||
let attempts = 0
|
||||
const { popup } = await readyPopup({
|
||||
options: () => {
|
||||
attempts += 1
|
||||
return attempts === 1 ? Promise.reject(new Error('directory down')) : Promise.resolve(OPTIONS)
|
||||
},
|
||||
})
|
||||
await Promise.resolve()
|
||||
popup.setSearch('da')
|
||||
// The failure landed before setSearch (readyPopup awaited); search must survive it and retry.
|
||||
expect(popup.state.getSnapshot()).toMatchObject({ open: true, status: 'failed', error: 'directory down', search: 'da' })
|
||||
popup.retry()
|
||||
expect(popup.state.getSnapshot()).toMatchObject({ status: 'pending', error: null })
|
||||
await Promise.resolve()
|
||||
expect(popup.state.getSnapshot()).toMatchObject({ status: 'ready', options: OPTIONS, search: 'da' })
|
||||
expect(attempts).toBe(2)
|
||||
})
|
||||
|
||||
it('retry is a no-op unless the options load failed', async () => {
|
||||
const { popup } = await readyPopup()
|
||||
popup.retry()
|
||||
expect(popup.state.getSnapshot().status).toBe('ready')
|
||||
const closed = new PopupSelectController<Ctx>(makeDeps())
|
||||
closed.retry()
|
||||
expect(closed.state.getSnapshot().open).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('search / move / highlight over the filtered list', () => {
|
||||
it('setSearch rebases the highlight to 0 and ignores closed shells and identical text', async () => {
|
||||
const { popup } = await readyPopup()
|
||||
popup.move(1)
|
||||
expect(popup.state.getSnapshot().active).toBe(1)
|
||||
popup.setSearch('s')
|
||||
expect(popup.state.getSnapshot()).toMatchObject({ search: 's', active: 0 })
|
||||
const before = popup.state.getSnapshot()
|
||||
popup.setSearch('s')
|
||||
expect(popup.state.getSnapshot()).toBe(before)
|
||||
const closed = new PopupSelectController<Ctx>(makeDeps())
|
||||
closed.setSearch('x')
|
||||
expect(closed.state.getSnapshot().search).toBe('')
|
||||
})
|
||||
|
||||
it('move wraps across the FILTERED rows', async () => {
|
||||
const { popup } = await readyPopup()
|
||||
popup.setSearch('a') // Dark, Sepia (detail 'warm' also matches 'a'? label match: Dark, Sepia)
|
||||
const rows = filterOptions(popup.state.getSnapshot().options, 'a')
|
||||
expect(rows.length).toBe(2)
|
||||
popup.move(1)
|
||||
expect(popup.state.getSnapshot().active).toBe(1)
|
||||
popup.move(1)
|
||||
expect(popup.state.getSnapshot().active).toBe(0)
|
||||
popup.move(-1)
|
||||
expect(popup.state.getSnapshot().active).toBe(1)
|
||||
})
|
||||
|
||||
it('move is a no-op while pending, closed, or when the filter matches nothing', async () => {
|
||||
const pending = new PopupSelectController<Ctx>(makeDeps())
|
||||
pending.open('theme', spec({ options: () => new Promise(() => {}) }), CTX_A, SEGMENT)
|
||||
pending.move(1)
|
||||
expect(pending.state.getSnapshot().active).toBe(0)
|
||||
const closed = new PopupSelectController<Ctx>(makeDeps())
|
||||
closed.move(1)
|
||||
expect(closed.state.getSnapshot().active).toBe(0)
|
||||
const { popup } = await readyPopup()
|
||||
popup.setSearch('nope')
|
||||
popup.move(1)
|
||||
expect(popup.state.getSnapshot().active).toBe(0)
|
||||
})
|
||||
|
||||
it('highlight sets the active filtered row and ignores out-of-range or same-index calls', async () => {
|
||||
const { popup } = await readyPopup()
|
||||
popup.highlight(1)
|
||||
expect(popup.state.getSnapshot().active).toBe(1)
|
||||
popup.highlight(99)
|
||||
popup.highlight(-1)
|
||||
popup.highlight(1)
|
||||
expect(popup.state.getSnapshot().active).toBe(1)
|
||||
popup.setSearch('dark') // one filtered row → index 1 now out of range
|
||||
popup.highlight(1)
|
||||
expect(popup.state.getSnapshot().active).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('select', () => {
|
||||
it('gates a confirmed option until acknowledgement, then settles through the original binding', async () => {
|
||||
const onSelect = vi.fn()
|
||||
const deps = makeDeps()
|
||||
const { popup } = await readyPopup({ options: () => Promise.resolve([GATED]), onSelect }, deps)
|
||||
await popup.select(0)
|
||||
expect(popup.state.getSnapshot()).toMatchObject({
|
||||
open: true, confirming: GATED, acknowledged: false, submitting: false,
|
||||
})
|
||||
expect(onSelect).not.toHaveBeenCalled()
|
||||
await popup.confirm()
|
||||
expect(onSelect).not.toHaveBeenCalled()
|
||||
popup.acknowledge(true)
|
||||
await popup.confirm()
|
||||
expect(onSelect).toHaveBeenCalledExactlyOnceWith(GATED, CTX_A)
|
||||
expect(deps.consume).toHaveBeenCalledExactlyOnceWith(SEGMENT)
|
||||
expect(popup.state.getSnapshot().open).toBe(false)
|
||||
})
|
||||
|
||||
it('cancels a confirmation back to the picker without selecting or consuming', async () => {
|
||||
const onSelect = vi.fn()
|
||||
const deps = makeDeps()
|
||||
const { popup } = await readyPopup({ options: () => Promise.resolve([GATED]), onSelect }, deps)
|
||||
await popup.select(0)
|
||||
popup.acknowledge(true)
|
||||
popup.cancelConfirmation()
|
||||
expect(popup.state.getSnapshot()).toMatchObject({
|
||||
open: true, confirming: null, acknowledged: false, submitting: false,
|
||||
})
|
||||
expect(onSelect).not.toHaveBeenCalled()
|
||||
expect(deps.consume).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('runs onSelect with the filtered option and the open-time context, consumes, closes, refocuses', async () => {
|
||||
const seen: Array<{ option: SelectOption; context: Ctx }> = []
|
||||
const deps = makeDeps()
|
||||
const { popup } = await readyPopup({
|
||||
onSelect: (option, context) => { seen.push({ option, context }) },
|
||||
}, deps)
|
||||
popup.setSearch('light')
|
||||
await popup.select(0)
|
||||
expect(seen).toEqual([{ option: OPTIONS[1], context: CTX_A }])
|
||||
expect(deps.consume).toHaveBeenCalledExactlyOnceWith(SEGMENT)
|
||||
expect(deps.focusComposer).toHaveBeenCalledTimes(1)
|
||||
expect(popup.state.getSnapshot().open).toBe(false)
|
||||
})
|
||||
|
||||
it('is single-flight: the first call enters submitting, later Enter/click calls no-op', async () => {
|
||||
let release!: () => void
|
||||
const onSelect = vi.fn(() => new Promise<void>((resolve) => { release = resolve }))
|
||||
const deps = makeDeps()
|
||||
const { popup } = await readyPopup({ onSelect }, deps)
|
||||
const first = popup.select(0)
|
||||
expect(popup.state.getSnapshot().submitting).toBe(true)
|
||||
await popup.select(0)
|
||||
await popup.select(1)
|
||||
popup.setSearch('x') // locked while submitting
|
||||
popup.move(1)
|
||||
popup.highlight(1)
|
||||
expect(popup.state.getSnapshot()).toMatchObject({ search: '', active: 0 })
|
||||
release()
|
||||
await first
|
||||
expect(onSelect).toHaveBeenCalledTimes(1)
|
||||
expect(deps.consume).toHaveBeenCalledTimes(1)
|
||||
expect(popup.state.getSnapshot().open).toBe(false)
|
||||
})
|
||||
|
||||
it('a consume CAS miss is benign: no retry, still closes and refocuses', async () => {
|
||||
const deps = makeDeps(false)
|
||||
const { popup } = await readyPopup({}, deps)
|
||||
await popup.select(0)
|
||||
expect(deps.consume).toHaveBeenCalledTimes(1)
|
||||
expect(deps.focusComposer).toHaveBeenCalledTimes(1)
|
||||
expect(popup.state.getSnapshot().open).toBe(false)
|
||||
})
|
||||
|
||||
it('an onSelect failure keeps the shell open with search/highlight/token intact, no consumption, and select re-arms', async () => {
|
||||
let attempts = 0
|
||||
const deps = makeDeps()
|
||||
const { popup } = await readyPopup({
|
||||
onSelect: () => {
|
||||
attempts += 1
|
||||
if (attempts === 1) throw new Error('host rejected')
|
||||
return undefined
|
||||
},
|
||||
}, deps)
|
||||
popup.setSearch('a')
|
||||
popup.move(1)
|
||||
await popup.select(1)
|
||||
expect(popup.state.getSnapshot()).toMatchObject({
|
||||
open: true, status: 'ready', submitting: false, error: 'host rejected', search: 'a', active: 1,
|
||||
})
|
||||
expect(deps.consume).not.toHaveBeenCalled()
|
||||
await popup.select(1) // retry = selecting again
|
||||
expect(deps.consume).toHaveBeenCalledExactlyOnceWith(SEGMENT)
|
||||
expect(popup.state.getSnapshot().open).toBe(false)
|
||||
})
|
||||
|
||||
it('ignores selects while closed, pending, failed, or out of filtered range', async () => {
|
||||
const closed = new PopupSelectController<Ctx>(makeDeps())
|
||||
await closed.select(0)
|
||||
expect(closed.state.getSnapshot().open).toBe(false)
|
||||
const failedDeps = makeDeps()
|
||||
const { popup: failed } = await readyPopup({ options: () => Promise.reject(new Error('x')) }, failedDeps)
|
||||
await failed.select(0)
|
||||
expect(failedDeps.consume).not.toHaveBeenCalled()
|
||||
const deps = makeDeps()
|
||||
const { popup } = await readyPopup({}, deps)
|
||||
popup.setSearch('dark')
|
||||
await popup.select(1) // only one filtered row
|
||||
expect(deps.consume).not.toHaveBeenCalled()
|
||||
expect(popup.state.getSnapshot().open).toBe(true)
|
||||
})
|
||||
|
||||
it('a dismiss racing a succeeding onSelect revokes it: no consume, no focus, state stays closed', async () => {
|
||||
let release!: () => void
|
||||
const deps = makeDeps()
|
||||
const { popup } = await readyPopup({
|
||||
onSelect: () => new Promise<void>((resolve) => { release = resolve }),
|
||||
}, deps)
|
||||
const selecting = popup.select(0)
|
||||
popup.dismiss()
|
||||
release()
|
||||
await selecting
|
||||
expect(deps.consume).not.toHaveBeenCalled()
|
||||
expect(deps.focusComposer).not.toHaveBeenCalled()
|
||||
expect(popup.state.getSnapshot().open).toBe(false)
|
||||
})
|
||||
|
||||
it('a dispose racing a failing onSelect revokes its error write', async () => {
|
||||
let reject!: (error: Error) => void
|
||||
const deps = makeDeps()
|
||||
const { popup } = await readyPopup({
|
||||
onSelect: () => new Promise<void>((_resolve, rej) => { reject = rej }),
|
||||
}, deps)
|
||||
const selecting = popup.select(0)
|
||||
popup.dispose()
|
||||
reject(new Error('late'))
|
||||
await selecting
|
||||
expect(popup.state.getSnapshot()).toMatchObject({ open: false, error: null })
|
||||
expect(deps.consume).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('a reopen racing a succeeding onSelect keeps the new shell: no consume of the old segment', async () => {
|
||||
let release!: () => void
|
||||
const deps = makeDeps()
|
||||
const { popup } = await readyPopup({
|
||||
onSelect: () => new Promise<void>((resolve) => { release = resolve }),
|
||||
}, deps)
|
||||
const selecting = popup.select(0)
|
||||
popup.open('other', spec(), CTX_A, { via: 'enter', token: '/other' })
|
||||
release()
|
||||
await selecting
|
||||
await Promise.resolve()
|
||||
expect(deps.consume).not.toHaveBeenCalled()
|
||||
expect(popup.state.getSnapshot()).toMatchObject({ open: true, command: 'other' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('dismiss / dispose', () => {
|
||||
it('dismiss closes, aborts the flying fetch, and is a no-op when already closed', async () => {
|
||||
const deps = makeDeps()
|
||||
const popup = new PopupSelectController<Ctx>(deps)
|
||||
let signal!: AbortSignal
|
||||
popup.open('theme', spec({
|
||||
options: (_ctx, s) => {
|
||||
signal = s
|
||||
return new Promise(() => {})
|
||||
},
|
||||
}), CTX_A, SEGMENT)
|
||||
popup.dismiss()
|
||||
expect(signal.aborted).toBe(true)
|
||||
expect(popup.state.getSnapshot().open).toBe(false)
|
||||
expect(deps.focusComposer).not.toHaveBeenCalled() // outside-pointer path: the click's target takes focus
|
||||
popup.dismiss()
|
||||
popup.dispose()
|
||||
expect(popup.state.getSnapshot().open).toBe(false)
|
||||
})
|
||||
|
||||
it('the Escape path restores composer focus explicitly', async () => {
|
||||
const deps = makeDeps()
|
||||
const { popup } = await readyPopup({}, deps)
|
||||
popup.dismiss({ focusComposer: true })
|
||||
expect(deps.focusComposer).toHaveBeenCalledTimes(1)
|
||||
expect(popup.state.getSnapshot().open).toBe(false)
|
||||
})
|
||||
})
|
||||
749
packages/client/ui-commands/tests/service.client.spec.ts
Normal file
749
packages/client/ui-commands/tests/service.client.spec.ts
Normal file
@@ -0,0 +1,749 @@
|
||||
/**
|
||||
* CommandUiRuntime tests on a real cordis Context with fake slash/connection
|
||||
* faces and real session scopes (createScope): session-keyed candidate
|
||||
* synthesis (host catalog by sessionId + contributions by availability,
|
||||
* collision fail-loud), the dispatch decision table cell by cell, matchSpace
|
||||
* hot-key policy, matchEnter strong-wait / reject, the sessionId execute
|
||||
* payload, the scoped consume-token dispatch, per-session popupFor
|
||||
* lifecycle, and the directory invalidation event subscriptions.
|
||||
*/
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { CommandResult } from '@deepseek-ai/dsh-commands/types'
|
||||
import { createScope, scopeOf } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ClientSessionContext, ConsumeTokenRequest, InputTriggerPick, InputTriggerSource } from '@deepseek-ai/dsh-client-ui-input-trigger/client'
|
||||
import type { CommandContribution, CommandDecoration, CommandUiSpec, SelectOption } from '../src/client/contract.ts'
|
||||
import type { CommandDescriptor } from '../src/client/directory.ts'
|
||||
import { CommandUiRuntime } from '../src/client/service.ts'
|
||||
|
||||
const sid = (k: string): SessionId => k as SessionId
|
||||
|
||||
/** The agent-backed session projection (single state; identity only). */
|
||||
const proj = (id: string): ClientSessionContext => ({ sessionId: sid(id) })
|
||||
|
||||
const S1_CMDS: CommandDescriptor[] = [
|
||||
{ name: 'plan', description: 'bare kind' },
|
||||
{ name: 'goal', description: 'leadingInput kind', input: { hint: 'goal text' } },
|
||||
]
|
||||
|
||||
const S2_CMDS: CommandDescriptor[] = [
|
||||
...S1_CMDS,
|
||||
{ name: 'attach', description: 'scoped shadow', input: { hint: 'path' } },
|
||||
]
|
||||
|
||||
type ExecuteValue = { matched: boolean; commandId?: string }
|
||||
|
||||
interface BenchOptions {
|
||||
/** Scripted catalog per list payload; default serves the fixed catalogs by session. */
|
||||
commands?: (payload: { sessionId: SessionId }) => Promise<{ commands: CommandDescriptor[] }>
|
||||
execute?: (payload: { sessionId: SessionId; line: string }) => Promise<ExecuteValue>
|
||||
addressed?: SessionId
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold one programmed answer into the generated Remote face's outcome: a
|
||||
* resolved value is the ok branch, a rejection is the transport failure the
|
||||
* carrier reports in the error branch instead of throwing at the caller.
|
||||
* @param produce - the scripted answer for one Remote method.
|
||||
* @returns the carried result the service reads.
|
||||
*/
|
||||
async function carried<T>(produce: () => Promise<T>) {
|
||||
try {
|
||||
return { ok: true as const, value: await produce() }
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false as const,
|
||||
error: {
|
||||
code: 'internal',
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
details: {},
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function bench(opts: BenchOptions = {}) {
|
||||
const ctx = new Context()
|
||||
const registered = new Map<string, InputTriggerSource>()
|
||||
const listCalls: Array<{ sessionId: SessionId }> = []
|
||||
const executeCalls: Array<{ sessionId: SessionId; line: string }> = []
|
||||
// The service reads the generated commands Remote, which delivers the
|
||||
// carrier's outcome, so a programmed failure answers the error branch.
|
||||
const commandsRemote = {
|
||||
list: async (sessionId: SessionId) => {
|
||||
listCalls.push({ sessionId })
|
||||
return await carried(async () => {
|
||||
const value = await (opts.commands ?? (p => Promise.resolve({
|
||||
commands: p.sessionId === sid('s2') ? S2_CMDS : S1_CMDS,
|
||||
})))({ sessionId })
|
||||
return value.commands
|
||||
})
|
||||
},
|
||||
execute: async (sessionId: SessionId, line: string) => {
|
||||
executeCalls.push({ sessionId, line })
|
||||
return await carried(async () => {
|
||||
const fallback = (): Promise<ExecuteValue> => Promise.resolve({ matched: true })
|
||||
const value = await (opts.execute ?? fallback)({ sessionId, line })
|
||||
return value.matched
|
||||
? { commandId: value.commandId ?? 'fake-command', result: { kind: 'success' as const } }
|
||||
: undefined
|
||||
})
|
||||
},
|
||||
}
|
||||
ctx.provide('inputTriggers', {
|
||||
registerSource(src: InputTriggerSource) {
|
||||
const key = `${src.trigger} ${src.name}`
|
||||
registered.set(key, src)
|
||||
return () => { registered.delete(key) }
|
||||
},
|
||||
})
|
||||
// Real scope tags behind a fake sessions face.
|
||||
const scopes = new Map<SessionId, { ctx: Context; fiber: { dispose(): Promise<void> } }>()
|
||||
ctx.provide('sessions', {
|
||||
scope: (id: SessionId) => scopes.get(id)?.ctx,
|
||||
scopeOf: (c: Context) => scopeOf(c),
|
||||
subagentAddress: (id: SessionId) => id === opts.addressed
|
||||
? { parentSessionId: sid('parent'), childSessionId: id, mode: 'continuable' as const }
|
||||
: undefined,
|
||||
})
|
||||
const forwarded = new Map<string, Array<(...args: never[]) => void>>()
|
||||
ctx.provide('remote', {
|
||||
commands: commandsRemote,
|
||||
$on: (event: string, listener: (...args: never[]) => void) => {
|
||||
const listeners = forwarded.get(event) ?? []
|
||||
listeners.push(listener)
|
||||
forwarded.set(event, listeners)
|
||||
return () => { forwarded.set(event, listeners.filter(entry => entry !== listener)) }
|
||||
},
|
||||
$dispatch: (event: string, args: readonly unknown[]) => {
|
||||
for (const listener of forwarded.get(event) ?? []) listener(...args as never[])
|
||||
},
|
||||
})
|
||||
ctx.provide('remote.commands', commandsRemote)
|
||||
const executions: Array<{ sessionId: SessionId; name: string; result: CommandResult }> = []
|
||||
ctx.on('command/executed', (sessionId, name, result) => {
|
||||
executions.push({ sessionId, name, result })
|
||||
})
|
||||
/** Notices the fake conversation face collected (runDetached routing). */
|
||||
const notices: Array<{ scope: SessionId | undefined; level: 'info' | 'error'; text: string }> = []
|
||||
ctx.provide('conversation', {
|
||||
input: {
|
||||
for: (actx: Context) => ({
|
||||
notify: (level: 'info' | 'error', text: string) => {
|
||||
notices.push({ scope: scopeOf(actx), level, text })
|
||||
},
|
||||
}),
|
||||
},
|
||||
})
|
||||
const fiber = ctx.plugin(CommandUiRuntime)
|
||||
await fiber.await()
|
||||
const command = ctx.get('commandUi') as CommandUiRuntime
|
||||
const source = registered.get('/ command')
|
||||
if (source === undefined) throw new Error('command source not registered')
|
||||
const mint = (key: string) => {
|
||||
const handle = createScope(ctx, sid(key))
|
||||
scopes.set(sid(key), handle)
|
||||
return handle
|
||||
}
|
||||
/** Warm one session's catalog through the source's own candidate pull. */
|
||||
const warm = async (session: ClientSessionContext) => {
|
||||
await source.candidates(session, { query: '', position: 'leading', signal: new AbortController().signal })
|
||||
}
|
||||
return { ctx, fiber, command, source, mint, warm, listCalls, executeCalls, executions, registered, notices }
|
||||
}
|
||||
|
||||
function menuPick(source: InputTriggerSource, name: string, session: ClientSessionContext, end?: number) {
|
||||
const pick: InputTriggerPick = {
|
||||
candidate: { name },
|
||||
session,
|
||||
position: 'leading',
|
||||
via: 'menu',
|
||||
span: { start: 0, end: end ?? name.length + 1, draftRev: 3 },
|
||||
}
|
||||
return source.onPick(pick)
|
||||
}
|
||||
|
||||
const themeUi = (over: Partial<CommandUiSpec> = {}): CommandUiSpec => ({
|
||||
kind: 'popupSelect',
|
||||
options: () => Promise.resolve([{ id: 'dark', label: 'Dark' }]),
|
||||
onSelect: () => undefined,
|
||||
...over,
|
||||
})
|
||||
|
||||
const themeContribution = (over: Partial<CommandContribution> = {}): CommandContribution => ({
|
||||
name: 'theme',
|
||||
description: 'client popup kind',
|
||||
available: () => true,
|
||||
ui: themeUi(),
|
||||
...over,
|
||||
})
|
||||
|
||||
const req = (query: string, position: 'leading' | 'inline' = 'leading') =>
|
||||
({ query, position, signal: new AbortController().signal })
|
||||
|
||||
describe('registration', () => {
|
||||
it('registers the "/" source with matchSpace/matchEnter/warm hooks and removes it on fiber disposal', async () => {
|
||||
const { registered, source, fiber } = await bench()
|
||||
expect(typeof source.matchSpace).toBe('function')
|
||||
expect(typeof source.matchEnter).toBe('function')
|
||||
expect(typeof source.warm).toBe('function')
|
||||
expect([...registered.keys()]).toEqual(['/ command'])
|
||||
await fiber.dispose()
|
||||
expect(registered.size).toBe(0)
|
||||
})
|
||||
|
||||
it('the warm hook prewarms the session key: one pull per session, no duplicate over pending', async () => {
|
||||
const { source, listCalls } = await bench()
|
||||
source.warm!(proj('s1'))
|
||||
expect(listCalls).toEqual([{ sessionId: sid('s1') }])
|
||||
source.warm!(proj('s2'))
|
||||
expect(listCalls).toEqual([{ sessionId: sid('s1') }, { sessionId: sid('s2') }])
|
||||
source.warm!(proj('s1')) // s1 already pending → no duplicate pull
|
||||
expect(listCalls).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('candidates', () => {
|
||||
it('does not fetch Agent-bound commands for an addressed child', async () => {
|
||||
const b = await bench({ addressed: sid('child') })
|
||||
await expect(b.warm(proj('child'))).resolves.toBeUndefined()
|
||||
expect(b.listCalls).toEqual([])
|
||||
})
|
||||
|
||||
it('pulls the session catalog; fuzzy filter and hint mapping apply', async () => {
|
||||
const { source, listCalls } = await bench()
|
||||
const list = await source.candidates(proj('s1'), req('g'))
|
||||
expect(listCalls).toEqual([{ sessionId: sid('s1') }])
|
||||
expect(list).toEqual([{ name: 'goal', description: 'leadingInput kind', hint: 'goal text' }])
|
||||
})
|
||||
|
||||
it('matches case-insensitive subsequences and ranks prefixes, boundaries, adjacency, gaps, then source order', async () => {
|
||||
const commands: CommandDescriptor[] = [
|
||||
{ name: 'q-xylophone', description: '' },
|
||||
{ name: 'qx-long', description: '' },
|
||||
{ name: 'fabulous', description: '' },
|
||||
{ name: 'foo-bar', description: '' },
|
||||
{ name: 'zuv', description: '' },
|
||||
{ name: 'zu1v', description: '' },
|
||||
{ name: 'yu1v', description: '' },
|
||||
{ name: 'zu12v', description: '' },
|
||||
]
|
||||
const { source } = await bench({ commands: () => Promise.resolve({ commands }) })
|
||||
const names = async (query: string) => (await source.candidates(proj('s1'), req(query))).map(c => c.name)
|
||||
await expect(names('QX')).resolves.toEqual(['qx-long', 'q-xylophone'])
|
||||
await expect(names('fb')).resolves.toEqual(['foo-bar', 'fabulous'])
|
||||
await expect(names('uv')).resolves.toEqual(['zuv', 'zu1v', 'yu1v', 'zu12v'])
|
||||
await expect(names('zzz')).resolves.toEqual([])
|
||||
await expect(names('query-longer-than-every-name')).resolves.toEqual([])
|
||||
})
|
||||
|
||||
it('catalogs are per session: another session pulls its own key', async () => {
|
||||
const { source, listCalls } = await bench()
|
||||
const names = (await source.candidates(proj('s2'), req(''))).map(c => c.name)
|
||||
expect(listCalls).toEqual([{ sessionId: sid('s2') }])
|
||||
expect(names).toEqual(['plan', 'goal', 'attach'])
|
||||
})
|
||||
|
||||
it('hides leadingInput commands at inline position', async () => {
|
||||
const { source } = await bench()
|
||||
const names = (await source.candidates(proj('s1'), req('', 'inline'))).map(c => c.name)
|
||||
expect(names).toEqual(['plan'])
|
||||
})
|
||||
|
||||
it('merges available contributions and filters unavailable ones with the per-call projection', async () => {
|
||||
const { command, source } = await bench()
|
||||
const available = vi.fn((session: ClientSessionContext) => session.sessionId === sid('s1'))
|
||||
command.register(themeContribution({ available }))
|
||||
const s1Names = (await source.candidates(proj('s1'), req(''))).map(c => c.name)
|
||||
expect(s1Names).toEqual(['plan', 'goal', 'theme'])
|
||||
expect(available).toHaveBeenLastCalledWith(proj('s1'))
|
||||
const s2Names = (await source.candidates(proj('s2'), req(''))).map(c => c.name)
|
||||
expect(s2Names).not.toContain('theme')
|
||||
})
|
||||
|
||||
it('contribution rows ride the same fuzzy query filter', async () => {
|
||||
const { command, source } = await bench()
|
||||
command.register(themeContribution())
|
||||
const names = (await source.candidates(proj('s1'), req('tm'))).map(c => c.name)
|
||||
expect(names).toEqual(['theme'])
|
||||
})
|
||||
|
||||
it('a contribution/host name collision fails loud', async () => {
|
||||
const { command, source } = await bench()
|
||||
command.register(themeContribution({ name: 'plan' }))
|
||||
await expect(source.candidates(proj('s1'), req(''))).rejects.toThrow('collides with a host command')
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
describe('decorations (bare-invocation UI on host commands)', () => {
|
||||
const goalDecoration = (over: Partial<CommandDecoration> = {}): CommandDecoration => ({
|
||||
name: 'goal',
|
||||
available: () => true,
|
||||
ui: themeUi(),
|
||||
...over,
|
||||
})
|
||||
|
||||
it('adds no catalog row: the host row stands alone', async () => {
|
||||
const { command, source } = await bench()
|
||||
command.decorate(goalDecoration())
|
||||
const names = (await source.candidates(proj('s1'), req(''))).map(c => c.name)
|
||||
expect(names).toEqual(['plan', 'goal'])
|
||||
})
|
||||
|
||||
it('bare enter opens the popup; an argued line never consults the decoration (host claim)', async () => {
|
||||
const { command, source, mint, warm } = await bench()
|
||||
command.decorate(goalDecoration())
|
||||
const scope = mint('s1')
|
||||
await warm(proj('s1'))
|
||||
expect(await source.matchEnter!(proj('s1'), '/goal', new AbortController().signal)).toBe('handled')
|
||||
expect(command.popupFor(scope.ctx).state.getSnapshot()).toMatchObject({ open: true, command: 'goal' })
|
||||
const argued = await source.matchEnter!(proj('s1'), '/goal ship it', new AbortController().signal)
|
||||
if (argued === undefined || argued === 'handled' || !('claim' in argued)) throw new Error('expected the host claim')
|
||||
expect(argued.claim.token).toBe('/goal ')
|
||||
})
|
||||
|
||||
it('space never consults the decoration (host claim)', async () => {
|
||||
const { command, source, warm } = await bench()
|
||||
command.decorate(goalDecoration())
|
||||
await warm(proj('s1'))
|
||||
const outcome = source.matchSpace!(proj('s1'), '/goal')
|
||||
if (outcome === undefined || outcome === 'handled' || !('claim' in outcome)) throw new Error('expected the host claim')
|
||||
expect(outcome.claim.token).toBe('/goal ')
|
||||
})
|
||||
|
||||
it('a decoration with no host row never fires (bare enter misses; menu pick misses)', async () => {
|
||||
const { command, source, mint, warm } = await bench()
|
||||
command.decorate(goalDecoration({ name: 'phantom' }))
|
||||
const scope = mint('s1')
|
||||
await warm(proj('s1'))
|
||||
expect(await source.matchEnter!(proj('s1'), '/phantom', new AbortController().signal)).toBeUndefined()
|
||||
expect(menuPick(source, 'phantom', proj('s1'))).toBeUndefined()
|
||||
expect(command.popupFor(scope.ctx).state.getSnapshot().open).toBe(false)
|
||||
})
|
||||
|
||||
it('an unavailable decoration falls through to the host bare path (detached execute)', async () => {
|
||||
const { command, source, warm, executeCalls } = await bench()
|
||||
command.decorate(goalDecoration({ name: 'plan', available: () => false }))
|
||||
await warm(proj('s1'))
|
||||
expect(await source.matchEnter!(proj('s1'), '/plan', new AbortController().signal)).toBe('handled')
|
||||
expect(executeCalls).toEqual([{ sessionId: sid('s1'), line: '/plan' }])
|
||||
})
|
||||
|
||||
it('duplicate decoration names fail loud', async () => {
|
||||
const { command } = await bench()
|
||||
command.decorate(goalDecoration())
|
||||
expect(() => { command.decorate(goalDecoration()) }).toThrow('duplicate decoration for /goal')
|
||||
})
|
||||
})
|
||||
|
||||
describe('dispatch (menu column)', () => {
|
||||
it('contribution → opens the session popup with the open-time projection, no execute', async () => {
|
||||
const { command, source, mint, warm, executeCalls } = await bench()
|
||||
const options = vi.fn((_s: ClientSessionContext) => Promise.resolve([{ id: 'dark', label: 'Dark' }]))
|
||||
command.register(themeContribution({ ui: themeUi({ options }) }))
|
||||
const scope = mint('s1')
|
||||
await warm(proj('s1'))
|
||||
expect(menuPick(source, 'theme', proj('s1'))).toBe('handled')
|
||||
const popup = command.popupFor(scope.ctx)
|
||||
expect(popup.state.getSnapshot()).toMatchObject({ open: true, command: 'theme' })
|
||||
expect(options).toHaveBeenCalledExactlyOnceWith(proj('s1'), expect.any(AbortSignal))
|
||||
expect(executeCalls).toEqual([])
|
||||
})
|
||||
|
||||
it('an unavailable contribution falls through to the host catalog', async () => {
|
||||
const { command, source, mint, warm } = await bench()
|
||||
command.register(themeContribution({ available: () => false }))
|
||||
const scope = mint('s1')
|
||||
await warm(proj('s1'))
|
||||
expect(menuPick(source, 'theme', proj('s1'))).toBeUndefined() // no host 'theme' either
|
||||
expect(command.popupFor(scope.ctx).state.getSnapshot().open).toBe(false)
|
||||
})
|
||||
|
||||
it('host leadingInput → {claim} with token "/name " and hint; claiming never executes', async () => {
|
||||
const { source, warm, executeCalls } = await bench()
|
||||
await warm(proj('s1'))
|
||||
const outcome = menuPick(source, 'goal', proj('s1'))
|
||||
if (outcome === undefined || outcome === 'handled' || !('claim' in outcome)) throw new Error('expected claim')
|
||||
expect(outcome.claim.token).toBe('/goal ')
|
||||
expect(outcome.claim.hint).toBe('goal text')
|
||||
expect(executeCalls).toEqual([])
|
||||
})
|
||||
|
||||
it('host bare → consume-token span guard on the session scope + detached execute', async () => {
|
||||
const { source, mint, warm, executeCalls, executions } = await bench()
|
||||
const scope = mint('s1')
|
||||
const consumes: ConsumeTokenRequest[] = []
|
||||
scope.ctx.on('slash/input-consume-token', (r) => {
|
||||
consumes.push(r)
|
||||
return true
|
||||
})
|
||||
await warm(proj('s1'))
|
||||
expect(menuPick(source, 'plan', proj('s1'), 5)).toBe('handled')
|
||||
expect(consumes).toEqual([{ guard: { kind: 'span', span: { start: 0, end: 5, draftRev: 3 } } }])
|
||||
await vi.waitFor(() => {
|
||||
expect(executeCalls).toEqual([{ sessionId: sid('s1'), line: '/plan' }])
|
||||
expect(executions).toEqual([{
|
||||
sessionId: sid('s1'),
|
||||
name: 'plan',
|
||||
result: { kind: 'success' },
|
||||
}])
|
||||
})
|
||||
})
|
||||
|
||||
it('a name the directory no longer serves → undefined (snapshot swapped between menu and pick)', async () => {
|
||||
const { source, warm } = await bench()
|
||||
await warm(proj('s1'))
|
||||
expect(menuPick(source, 'gone', proj('s1'))).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('matchSpace (space column)', () => {
|
||||
it('answers undefined from a not-ready key (no waiting, no RPC)', async () => {
|
||||
const { source, listCalls } = await bench()
|
||||
expect(source.matchSpace!(proj('s1'), '/goal')).toBeUndefined()
|
||||
expect(listCalls).toEqual([])
|
||||
})
|
||||
|
||||
it('hot leadingInput exact token → {claim}; the key axis is the session', async () => {
|
||||
const { source, warm } = await bench()
|
||||
await warm(proj('s2'))
|
||||
const outcome = source.matchSpace!(proj('s2'), '/attach')
|
||||
if (outcome === undefined || outcome === 'handled' || !('claim' in outcome)) throw new Error('expected claim')
|
||||
expect(outcome.claim.token).toBe('/attach ')
|
||||
// s1's key is still cold: the same token answers undefined there.
|
||||
expect(source.matchSpace!(proj('s1'), '/attach')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('bare kind and contribution names stay plain text', async () => {
|
||||
const { command, source, warm } = await bench()
|
||||
command.register(themeContribution())
|
||||
await warm(proj('s1'))
|
||||
expect(source.matchSpace!(proj('s1'), '/plan')).toBeUndefined()
|
||||
expect(source.matchSpace!(proj('s1'), '/theme')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('unknown token / non-slash token → undefined', async () => {
|
||||
const { source, warm } = await bench()
|
||||
await warm(proj('s1'))
|
||||
expect(source.matchSpace!(proj('s1'), '/nope')).toBeUndefined()
|
||||
expect(source.matchSpace!(proj('s1'), 'plan')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('matchEnter (enter column)', () => {
|
||||
const signal = () => new AbortController().signal
|
||||
|
||||
it('strong-waits a cold key before adjudicating', async () => {
|
||||
let release!: (value: { commands: CommandDescriptor[] }) => void
|
||||
const { source } = await bench({
|
||||
commands: () => new Promise((resolve) => { release = resolve }),
|
||||
})
|
||||
const wait = source.matchEnter!(proj('s1'), '/goal args', signal())
|
||||
release({ commands: S1_CMDS })
|
||||
const outcome = await wait
|
||||
if (outcome === undefined || outcome === 'handled' || !('claim' in outcome)) throw new Error('expected claim')
|
||||
expect(outcome.claim.token).toBe('/goal ')
|
||||
})
|
||||
|
||||
it('rejects when warmup fails (never a silent downgrade)', async () => {
|
||||
const { source } = await bench({
|
||||
commands: () => Promise.reject(new Error('warmup boom')),
|
||||
})
|
||||
await expect(source.matchEnter!(proj('s1'), '/goal', signal())).rejects.toThrow('warmup boom')
|
||||
})
|
||||
|
||||
it('leadingInput claims args-tolerant (bare and with trailing text)', async () => {
|
||||
const { source, warm } = await bench()
|
||||
await warm(proj('s1'))
|
||||
for (const line of ['/goal', '/goal refactor the loop']) {
|
||||
const outcome = await source.matchEnter!(proj('s1'), line, signal())
|
||||
if (outcome === undefined || outcome === 'handled' || !('claim' in outcome)) throw new Error('expected claim')
|
||||
expect(outcome.claim.token).toBe('/goal ')
|
||||
}
|
||||
})
|
||||
|
||||
it('bare host command executes detached with the bare-token consume guard', async () => {
|
||||
const { source, mint, warm, executeCalls } = await bench()
|
||||
const scope = mint('s1')
|
||||
const consumes: ConsumeTokenRequest[] = []
|
||||
scope.ctx.on('slash/input-consume-token', (r) => {
|
||||
consumes.push(r)
|
||||
return true
|
||||
})
|
||||
await warm(proj('s1'))
|
||||
await expect(source.matchEnter!(proj('s1'), '/plan', signal())).resolves.toBe('handled')
|
||||
expect(consumes).toEqual([{ guard: { kind: 'bare-token', token: '/plan' } }])
|
||||
await Promise.resolve()
|
||||
expect(executeCalls).toEqual([{ sessionId: sid('s1'), line: '/plan' }])
|
||||
})
|
||||
|
||||
it('bare kind with trailing text → undefined and no RPC (default sink owns the line)', async () => {
|
||||
const { source, warm, executeCalls } = await bench()
|
||||
await warm(proj('s1'))
|
||||
await expect(source.matchEnter!(proj('s1'), '/plan now', signal())).resolves.toBeUndefined()
|
||||
expect(executeCalls).toEqual([])
|
||||
})
|
||||
|
||||
it('contribution: bare token opens the popup without touching the directory; args → undefined', async () => {
|
||||
const { command, source, mint, listCalls } = await bench()
|
||||
command.register(themeContribution())
|
||||
const scope = mint('s1')
|
||||
await expect(source.matchEnter!(proj('s1'), '/theme', signal())).resolves.toBe('handled')
|
||||
expect(command.popupFor(scope.ctx).state.getSnapshot().open).toBe(true)
|
||||
expect(listCalls).toEqual([]) // contribution short-circuits ahead of ensureReady
|
||||
await expect(source.matchEnter!(proj('s1'), '/theme dark', signal())).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('unknown name, bare "/", and non-slash lines → undefined', async () => {
|
||||
const { source, warm } = await bench()
|
||||
await warm(proj('s1'))
|
||||
await expect(source.matchEnter!(proj('s1'), '/nope', signal())).resolves.toBeUndefined()
|
||||
await expect(source.matchEnter!(proj('s1'), '/', signal())).resolves.toBeUndefined()
|
||||
await expect(source.matchEnter!(proj('s1'), 'plain text', signal())).resolves.toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('execute payload', () => {
|
||||
it('claim.submit addresses the session; admitted outcomes stay off the composer (flow card owns them)', async () => {
|
||||
const { source, warm, executeCalls, executions } = await bench({
|
||||
execute: () => Promise.resolve({ matched: true }),
|
||||
})
|
||||
await warm(proj('s1'))
|
||||
const outcome = source.matchSpace!(proj('s1'), '/goal')
|
||||
if (outcome === undefined || outcome === 'handled' || !('claim' in outcome)) throw new Error('expected claim')
|
||||
const settled = await outcome.claim.submit('ship it', new Context())
|
||||
expect(executeCalls).toEqual([{ sessionId: sid('s1'), line: '/goal ship it' }])
|
||||
// Pure admission: no outcome text ever rides the submit result — the
|
||||
// durable command lifecycle events render the outcome in the flow.
|
||||
expect(settled).toEqual({ kind: 'success' })
|
||||
expect(executions).toEqual([{
|
||||
sessionId: sid('s1'),
|
||||
name: 'goal',
|
||||
result: { kind: 'success' },
|
||||
}])
|
||||
})
|
||||
|
||||
it('contains local acknowledgment listeners without changing an admitted result', async () => {
|
||||
const b = await bench({ execute: () => Promise.resolve({ matched: true }) })
|
||||
await b.warm(proj('s1'))
|
||||
const outcome = b.source.matchSpace!(proj('s1'), '/goal')
|
||||
if (outcome === undefined || outcome === 'handled' || !('claim' in outcome)) throw new Error('expected claim')
|
||||
const syncFailure = new Error('sync observer failed')
|
||||
const asyncFailure = new Error('async observer failed')
|
||||
const after = vi.fn()
|
||||
const warn = vi.spyOn(b.ctx.logger, 'warn').mockImplementation(() => undefined)
|
||||
b.ctx.on('command/executed', () => { throw syncFailure })
|
||||
const rejectingListener = (() => Promise.reject(asyncFailure)) as unknown as () => void
|
||||
b.ctx.on('command/executed', rejectingListener)
|
||||
b.ctx.on('command/executed', after)
|
||||
|
||||
await expect(outcome.claim.submit('ship it', new Context())).resolves.toEqual({ kind: 'success' })
|
||||
expect(after).toHaveBeenCalledOnce()
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(warn).toHaveBeenCalledWith('client command: a command/executed listener for "%s" failed', 'goal')
|
||||
expect(warn).toHaveBeenCalledWith(syncFailure)
|
||||
expect(warn).toHaveBeenCalledWith(asyncFailure)
|
||||
})
|
||||
|
||||
it('maps matched:false to an error outcome and a matched bare result to success', async () => {
|
||||
const claimOf = async (opts: BenchOptions) => {
|
||||
const b = await bench(opts)
|
||||
await b.warm(proj('s1'))
|
||||
const outcome = b.source.matchSpace!(proj('s1'), '/goal')
|
||||
if (outcome === undefined || outcome === 'handled' || !('claim' in outcome)) throw new Error('expected claim')
|
||||
return outcome.claim
|
||||
}
|
||||
const first = await claimOf({ execute: () => Promise.resolve({ matched: false }) })
|
||||
const bad = await first.submit('x', new Context())
|
||||
expect(bad.kind).toBe('error')
|
||||
const second = await claimOf({ execute: () => Promise.resolve({ matched: true }) })
|
||||
await expect(second.submit('', new Context())).resolves.toEqual({ kind: 'success' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('detached admission notices', () => {
|
||||
const flush = () => new Promise(resolve => setTimeout(resolve, 0))
|
||||
|
||||
it('admitted outcomes stay silent; admission miss and transport rejection notice as errors', async () => {
|
||||
let mode: 'admitted' | 'miss' | 'reject' = 'admitted'
|
||||
const { source, mint, warm, notices } = await bench({
|
||||
execute: () => {
|
||||
if (mode === 'reject') return Promise.reject(new Error('network down'))
|
||||
return Promise.resolve({ matched: mode === 'admitted' })
|
||||
},
|
||||
})
|
||||
mint('s1')
|
||||
await warm(proj('s1'))
|
||||
// Admitted: the durable lifecycle events own the outcome — no notice.
|
||||
menuPick(source, 'plan', proj('s1'))
|
||||
await flush()
|
||||
expect(notices).toEqual([])
|
||||
|
||||
// Admission miss (matched:false): immediate composer feedback stays.
|
||||
mode = 'miss'
|
||||
await source.matchEnter!(proj('s1'), '/plan', new AbortController().signal)
|
||||
await flush()
|
||||
expect(notices).toEqual([{ scope: sid('s1'), level: 'error', text: 'unknown or malformed command: /plan' }])
|
||||
|
||||
notices.length = 0
|
||||
mode = 'reject'
|
||||
menuPick(source, 'plan', proj('s1'))
|
||||
await flush()
|
||||
// A dead Remote call and a rejected one now read alike: both arrive as a
|
||||
// failed result, so the notice names the endpoint either way.
|
||||
expect(notices).toEqual([{
|
||||
scope: sid('s1'),
|
||||
level: 'error',
|
||||
text: 'command.execute failed: internal: network down',
|
||||
}])
|
||||
})
|
||||
|
||||
it('a torn-down scope drops the failure notice', async () => {
|
||||
const { source, warm, notices } = await bench({
|
||||
execute: () => Promise.reject(new Error('orphan failure')),
|
||||
})
|
||||
await warm(proj('ghost')) // never minted: scopeFor misses
|
||||
menuPick(source, 'plan', proj('ghost'))
|
||||
await flush()
|
||||
expect(notices).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('register (contribution face)', () => {
|
||||
it('duplicate registration throws; the disposer frees the name', async () => {
|
||||
const { command } = await bench()
|
||||
const dispose = command.register(themeContribution())
|
||||
expect(() => command.register(themeContribution())).toThrow('duplicate contribution')
|
||||
dispose()
|
||||
command.register(themeContribution())()
|
||||
})
|
||||
})
|
||||
|
||||
describe('popupFor', () => {
|
||||
it('resolves lazily per session; a foreign session gets its own controller; unscoped ctx throws', async () => {
|
||||
const { ctx, command, mint } = await bench()
|
||||
const a = mint('s1')
|
||||
const first = command.popupFor(a.ctx)
|
||||
expect(command.popupFor(a.ctx)).toBe(first)
|
||||
expect(command.popupFor(mint('s2').ctx)).not.toBe(first)
|
||||
expect(() => command.popupFor(ctx)).toThrow('requires a session scope')
|
||||
})
|
||||
|
||||
it('a successful select dispatches the scoped consume-token and fires the bound composer focus', async () => {
|
||||
const { command, source, mint } = await bench()
|
||||
const onSelect = vi.fn()
|
||||
command.register(themeContribution({ ui: themeUi({ onSelect }) }))
|
||||
const scope = mint('s1')
|
||||
const consumes: ConsumeTokenRequest[] = []
|
||||
scope.ctx.on('slash/input-consume-token', (r) => {
|
||||
consumes.push(r)
|
||||
return true
|
||||
})
|
||||
const focus = vi.fn()
|
||||
command.bindComposerFocus(sid('s1'), focus)
|
||||
|
||||
expect(menuPick(source, 'theme', proj('s1'), 6)).toBe('handled')
|
||||
const popup = command.popupFor(scope.ctx)
|
||||
await Promise.resolve() // options land
|
||||
await popup.select(0)
|
||||
expect(onSelect).toHaveBeenCalledExactlyOnceWith({ id: 'dark', label: 'Dark' } satisfies SelectOption, proj('s1'))
|
||||
expect(consumes).toEqual([{ guard: { kind: 'span', span: { start: 0, end: 6, draftRev: 3 } } }])
|
||||
expect(focus).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('the enter path opens with the bare-token guard', async () => {
|
||||
const { command, source, mint } = await bench()
|
||||
command.register(themeContribution())
|
||||
const scope = mint('s1')
|
||||
const consumes: ConsumeTokenRequest[] = []
|
||||
scope.ctx.on('slash/input-consume-token', (r) => {
|
||||
consumes.push(r)
|
||||
return true
|
||||
})
|
||||
await source.matchEnter!(proj('s1'), '/theme', new AbortController().signal)
|
||||
const popup = command.popupFor(scope.ctx)
|
||||
await Promise.resolve()
|
||||
await popup.select(0)
|
||||
expect(consumes).toEqual([{ guard: { kind: 'bare-token', token: '/theme' } }])
|
||||
})
|
||||
|
||||
it('the scope disposer disposes the controller and a re-mint resolves fresh', async () => {
|
||||
const { command, source, mint } = await bench()
|
||||
command.register(themeContribution())
|
||||
const scope = mint('s1')
|
||||
await source.matchEnter!(proj('s1'), '/theme', new AbortController().signal)
|
||||
const popup = command.popupFor(scope.ctx)
|
||||
expect(popup.state.getSnapshot().open).toBe(true)
|
||||
|
||||
await scope.fiber.dispose()
|
||||
expect(popup.state.getSnapshot().open).toBe(false)
|
||||
expect(command.popupFor(mint('s1').ctx)).not.toBe(popup)
|
||||
})
|
||||
})
|
||||
|
||||
describe('directory invalidation events', () => {
|
||||
it('commands/change repulls in the background while the old snapshot serves', async () => {
|
||||
let round = 0
|
||||
const { ctx, source, warm } = await bench({
|
||||
commands: () => {
|
||||
round += 1
|
||||
return Promise.resolve({
|
||||
commands: round === 1
|
||||
? S1_CMDS
|
||||
: [{ name: 'fresh', description: '', input: { hint: 'h' } }],
|
||||
})
|
||||
},
|
||||
})
|
||||
await warm(proj('s1'))
|
||||
ctx.remote.$dispatch('commands/change', [])
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
expect(source.matchSpace!(proj('s1'), '/fresh')).not.toBeUndefined()
|
||||
expect(source.matchSpace!(proj('s1'), '/goal')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('agent-preset/selected repulls the recomposed session and leaves the others served', async () => {
|
||||
const rounds = new Map<SessionId, number>()
|
||||
const { ctx, source, warm } = await bench({
|
||||
commands: (payload) => {
|
||||
const round = (rounds.get(payload.sessionId) ?? 0) + 1
|
||||
rounds.set(payload.sessionId, round)
|
||||
return Promise.resolve({
|
||||
commands: round === 1
|
||||
? S1_CMDS
|
||||
: [{ name: 'fresh', description: '', input: { hint: 'h' } }],
|
||||
})
|
||||
},
|
||||
})
|
||||
await warm(proj('s1'))
|
||||
await warm(proj('s2'))
|
||||
// A preset switch changes which commands one session's agent resolves;
|
||||
// every other session keeps the catalog its own composition serves.
|
||||
ctx.remote.$dispatch('agent-preset/selected', [sid('s1'), 'minimal'])
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
expect(source.matchSpace!(proj('s1'), '/fresh')).not.toBeUndefined()
|
||||
expect(source.matchSpace!(proj('s1'), '/goal')).toBeUndefined()
|
||||
expect(source.matchSpace!(proj('s2'), '/goal')).not.toBeUndefined()
|
||||
})
|
||||
|
||||
it('connection/reset hard-drops every session key until its rewarm lands', async () => {
|
||||
let block = false
|
||||
let release!: (value: { commands: CommandDescriptor[] }) => void
|
||||
const { ctx, source, warm } = await bench({
|
||||
commands: () => (block
|
||||
? new Promise((resolve) => { release = resolve })
|
||||
: Promise.resolve({ commands: S2_CMDS })),
|
||||
})
|
||||
await warm(proj('s2'))
|
||||
expect(source.matchSpace!(proj('s2'), '/attach')).not.toBeUndefined()
|
||||
block = true
|
||||
ctx.emit('connection/reset')
|
||||
// Hard reset: silent until the rewarm lands.
|
||||
expect(source.matchSpace!(proj('s2'), '/attach')).toBeUndefined()
|
||||
release({ commands: S2_CMDS })
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
expect(source.matchSpace!(proj('s2'), '/attach')).not.toBeUndefined()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user