Bare /permission now opens a flat popupSelect of presets (current value active, custom excluded) instead of returning a text report — the /model pattern on a single-level list. The new dsh-client-ui-permission package registers the contribution; a pick submits '/permission <preset>' through Session.command, so the picker, the composer chip, and the argued line all write through the one host command and follow the one pushed projection frame. Options and availability read the 'permissions' projection. ui-command gains the hostBacked contribution mode: a same-named host command is cooperation, not a collision — the host keeps the catalog row, the argument claim (space and argued-enter fall through to the host path), and the lifecycle logging, while the contribution supplies only the bare-invocation popup. The /permission command handler keeps its bare-line text report for host surfaces without a popup layer (TUI, raw execute).
577 lines
25 KiB
TypeScript
577 lines
25 KiB
TypeScript
/**
|
|
* CommandService 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 'cordis'
|
|
import { describe, expect, it, vi } from 'vitest'
|
|
import { createScope, scopeOf } from '@deepseek-ai/dsh-client-runtime/client'
|
|
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
|
import type { ClientSessionContext, ConsumeTokenRequest, SlashPick, SlashSource } from '@deepseek-ai/dsh-client-ui-slash/client'
|
|
import type { CommandContribution, CommandUiSpec, SelectOption } from '../src/client/contract.ts'
|
|
import type { CommandDescriptor } from '../src/client/directory.ts'
|
|
import { CommandService } 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 }
|
|
|
|
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>
|
|
}
|
|
|
|
async function bench(opts: BenchOptions = {}) {
|
|
const ctx = new Context()
|
|
const registered = new Map<string, SlashSource>()
|
|
const listCalls: Array<{ sessionId: SessionId }> = []
|
|
const executeCalls: Array<{ sessionId: SessionId; line: string }> = []
|
|
const api = {
|
|
commands: {
|
|
list: async (payload: { sessionId: SessionId }) => {
|
|
listCalls.push(payload)
|
|
const value = await (opts.commands ?? (p => Promise.resolve({
|
|
commands: p.sessionId === sid('s2') ? S2_CMDS : S1_CMDS,
|
|
})))(payload)
|
|
return { result: { ok: true as const, value } }
|
|
},
|
|
execute: async (payload: { sessionId: SessionId; line: string }) => {
|
|
executeCalls.push(payload)
|
|
const value = await (opts.execute ?? (() => Promise.resolve({ matched: true })))(payload)
|
|
return { result: { ok: true as const, value } }
|
|
},
|
|
},
|
|
}
|
|
ctx.provide('slash', {
|
|
registerSource(src: SlashSource) {
|
|
const key = `${src.trigger} ${src.name}`
|
|
registered.set(key, src)
|
|
return () => { registered.delete(key) }
|
|
},
|
|
})
|
|
// Real scope tags behind a fake sessions face (scope/scopeOf are all the service reads).
|
|
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),
|
|
})
|
|
ctx.provide('connection', { api })
|
|
/** 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(CommandService)
|
|
await fiber.await()
|
|
const command = ctx.get('command') as CommandService
|
|
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, registered, notices }
|
|
}
|
|
|
|
function menuPick(source: SlashSource, name: string, session: ClientSessionContext, end?: number) {
|
|
const pick: SlashPick = {
|
|
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('pulls the session catalog; prefix 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('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 query prefix filter', async () => {
|
|
const { command, source } = await bench()
|
|
command.register(themeContribution())
|
|
const names = (await source.candidates(proj('s1'), req('th'))).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')
|
|
})
|
|
|
|
it('a hostBacked contribution cooperates: the host row stands, no duplicate, no throw', async () => {
|
|
const { command, source } = await bench()
|
|
command.register(themeContribution({ name: 'goal', hostBacked: true }))
|
|
const names = (await source.candidates(proj('s1'), req(''))).map(c => c.name)
|
|
expect(names).toEqual(['plan', 'goal'])
|
|
})
|
|
})
|
|
|
|
describe('hostBacked enter/space columns', () => {
|
|
it('bare enter opens the popup; an argued line falls through to the host claim', async () => {
|
|
const { command, source, mint, warm } = await bench()
|
|
command.register(themeContribution({ name: 'goal', hostBacked: true }))
|
|
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 defers to the host claim instead of the popup', async () => {
|
|
const { command, source, warm } = await bench()
|
|
command.register(themeContribution({ name: 'goal', hostBacked: true }))
|
|
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 ')
|
|
})
|
|
})
|
|
|
|
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 } = 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 Promise.resolve()
|
|
expect(executeCalls).toEqual([{ sessionId: sid('s1'), line: '/plan' }])
|
|
})
|
|
|
|
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 } = 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' })
|
|
})
|
|
|
|
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()
|
|
expect(notices).toEqual([{ scope: sid('s1'), level: 'error', text: '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/changed 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.emit('commands/changed')
|
|
await new Promise(resolve => setTimeout(resolve, 0))
|
|
expect(source.matchSpace!(proj('s1'), '/fresh')).not.toBeUndefined()
|
|
expect(source.matchSpace!(proj('s1'), '/goal')).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()
|
|
})
|
|
})
|