service.spec.ts 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548
  1. /**
  2. * CommandService tests on a real cordis Context with fake slash/connection
  3. * faces and real session scopes (createScope): session-keyed candidate
  4. * synthesis (host catalog by sessionId + contributions by availability,
  5. * collision fail-loud), the dispatch decision table cell by cell, matchSpace
  6. * hot-key policy, matchEnter strong-wait / reject, the sessionId execute
  7. * payload, the scoped consume-token dispatch, per-session popupFor
  8. * lifecycle, and the directory invalidation event subscriptions.
  9. */
  10. import { Context } from 'cordis'
  11. import { describe, expect, it, vi } from 'vitest'
  12. import { createScope, scopeOf } from '@deepseek-ai/dsh-client-runtime/client'
  13. import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
  14. import type { ClientSessionContext, ConsumeTokenRequest, SlashPick, SlashSource } from '@deepseek-ai/dsh-client-ui-slash/client'
  15. import type { CommandContribution, CommandUiSpec, SelectOption } from '../src/client/contract.ts'
  16. import type { CommandDescriptor } from '../src/client/directory.ts'
  17. import { CommandService } from '../src/client/service.ts'
  18. const sid = (k: string): SessionId => k as SessionId
  19. /** The agent-backed session projection (single state; identity only). */
  20. const proj = (id: string): ClientSessionContext => ({ sessionId: sid(id) })
  21. const S1_CMDS: CommandDescriptor[] = [
  22. { name: 'plan', description: 'bare kind' },
  23. { name: 'goal', description: 'leadingInput kind', input: { hint: 'goal text' } },
  24. ]
  25. const S2_CMDS: CommandDescriptor[] = [
  26. ...S1_CMDS,
  27. { name: 'attach', description: 'scoped shadow', input: { hint: 'path' } },
  28. ]
  29. type ExecuteValue = { matched: boolean; result?: { kind: 'success' | 'error'; text?: string } }
  30. interface BenchOptions {
  31. /** Scripted catalog per list payload; default serves the fixed catalogs by session. */
  32. commands?: (payload: { sessionId: SessionId }) => Promise<{ commands: CommandDescriptor[] }>
  33. execute?: (payload: { sessionId: SessionId; line: string }) => Promise<ExecuteValue>
  34. }
  35. async function bench(opts: BenchOptions = {}) {
  36. const ctx = new Context()
  37. const registered = new Map<string, SlashSource>()
  38. const listCalls: Array<{ sessionId: SessionId }> = []
  39. const executeCalls: Array<{ sessionId: SessionId; line: string }> = []
  40. const api = {
  41. commands: {
  42. list: async (payload: { sessionId: SessionId }) => {
  43. listCalls.push(payload)
  44. const value = await (opts.commands ?? (p => Promise.resolve({
  45. commands: p.sessionId === sid('s2') ? S2_CMDS : S1_CMDS,
  46. })))(payload)
  47. return { result: { ok: true as const, value } }
  48. },
  49. execute: async (payload: { sessionId: SessionId; line: string }) => {
  50. executeCalls.push(payload)
  51. const value = await (opts.execute ?? (() => Promise.resolve({ matched: true })))(payload)
  52. return { result: { ok: true as const, value } }
  53. },
  54. },
  55. }
  56. ctx.provide('slash', {
  57. registerSource(src: SlashSource) {
  58. const key = `${src.trigger} ${src.name}`
  59. registered.set(key, src)
  60. return () => { registered.delete(key) }
  61. },
  62. })
  63. // Real scope tags behind a fake sessions face (scope/scopeOf are all the service reads).
  64. const scopes = new Map<SessionId, { ctx: Context; fiber: { dispose(): Promise<void> } }>()
  65. ctx.provide('sessions', {
  66. scope: (id: SessionId) => scopes.get(id)?.ctx,
  67. scopeOf: (c: Context) => scopeOf(c),
  68. })
  69. ctx.provide('connection', { api })
  70. /** Notices the fake conversation face collected (runDetached routing). */
  71. const notices: Array<{ scope: SessionId | undefined; level: 'info' | 'error'; text: string }> = []
  72. ctx.provide('conversation', {
  73. input: {
  74. for: (actx: Context) => ({
  75. notify: (level: 'info' | 'error', text: string) => {
  76. notices.push({ scope: scopeOf(actx), level, text })
  77. },
  78. }),
  79. },
  80. })
  81. const fiber = ctx.plugin(CommandService)
  82. await fiber.await()
  83. const command = ctx.get('command') as CommandService
  84. const source = registered.get('/ command')
  85. if (source === undefined) throw new Error('command source not registered')
  86. const mint = (key: string) => {
  87. const handle = createScope(ctx, sid(key))
  88. scopes.set(sid(key), handle)
  89. return handle
  90. }
  91. /** Warm one session's catalog through the source's own candidate pull. */
  92. const warm = async (session: ClientSessionContext) => {
  93. await source.candidates(session, { query: '', position: 'leading', signal: new AbortController().signal })
  94. }
  95. return { ctx, fiber, command, source, mint, warm, listCalls, executeCalls, registered, notices }
  96. }
  97. function menuPick(source: SlashSource, name: string, session: ClientSessionContext, end?: number) {
  98. const pick: SlashPick = {
  99. candidate: { name },
  100. session,
  101. position: 'leading',
  102. via: 'menu',
  103. span: { start: 0, end: end ?? name.length + 1, draftRev: 3 },
  104. }
  105. return source.onPick(pick)
  106. }
  107. const themeUi = (over: Partial<CommandUiSpec> = {}): CommandUiSpec => ({
  108. kind: 'popupSelect',
  109. options: () => Promise.resolve([{ id: 'dark', label: 'Dark' }]),
  110. onSelect: () => undefined,
  111. ...over,
  112. })
  113. const themeContribution = (over: Partial<CommandContribution> = {}): CommandContribution => ({
  114. name: 'theme',
  115. description: 'client popup kind',
  116. available: () => true,
  117. ui: themeUi(),
  118. ...over,
  119. })
  120. const req = (query: string, position: 'leading' | 'inline' = 'leading') =>
  121. ({ query, position, signal: new AbortController().signal })
  122. describe('registration', () => {
  123. it('registers the "/" source with matchSpace/matchEnter/warm hooks and removes it on fiber disposal', async () => {
  124. const { registered, source, fiber } = await bench()
  125. expect(typeof source.matchSpace).toBe('function')
  126. expect(typeof source.matchEnter).toBe('function')
  127. expect(typeof source.warm).toBe('function')
  128. expect([...registered.keys()]).toEqual(['/ command'])
  129. await fiber.dispose()
  130. expect(registered.size).toBe(0)
  131. })
  132. it('the warm hook prewarms the session key: one pull per session, no duplicate over pending', async () => {
  133. const { source, listCalls } = await bench()
  134. source.warm!(proj('s1'))
  135. expect(listCalls).toEqual([{ sessionId: sid('s1') }])
  136. source.warm!(proj('s2'))
  137. expect(listCalls).toEqual([{ sessionId: sid('s1') }, { sessionId: sid('s2') }])
  138. source.warm!(proj('s1')) // s1 already pending → no duplicate pull
  139. expect(listCalls).toHaveLength(2)
  140. })
  141. })
  142. describe('candidates', () => {
  143. it('pulls the session catalog; prefix filter and hint mapping apply', async () => {
  144. const { source, listCalls } = await bench()
  145. const list = await source.candidates(proj('s1'), req('g'))
  146. expect(listCalls).toEqual([{ sessionId: sid('s1') }])
  147. expect(list).toEqual([{ name: 'goal', description: 'leadingInput kind', hint: 'goal text' }])
  148. })
  149. it('catalogs are per session: another session pulls its own key', async () => {
  150. const { source, listCalls } = await bench()
  151. const names = (await source.candidates(proj('s2'), req(''))).map(c => c.name)
  152. expect(listCalls).toEqual([{ sessionId: sid('s2') }])
  153. expect(names).toEqual(['plan', 'goal', 'attach'])
  154. })
  155. it('hides leadingInput commands at inline position', async () => {
  156. const { source } = await bench()
  157. const names = (await source.candidates(proj('s1'), req('', 'inline'))).map(c => c.name)
  158. expect(names).toEqual(['plan'])
  159. })
  160. it('merges available contributions and filters unavailable ones with the per-call projection', async () => {
  161. const { command, source } = await bench()
  162. const available = vi.fn((session: ClientSessionContext) => session.sessionId === sid('s1'))
  163. command.register(themeContribution({ available }))
  164. const s1Names = (await source.candidates(proj('s1'), req(''))).map(c => c.name)
  165. expect(s1Names).toEqual(['plan', 'goal', 'theme'])
  166. expect(available).toHaveBeenLastCalledWith(proj('s1'))
  167. const s2Names = (await source.candidates(proj('s2'), req(''))).map(c => c.name)
  168. expect(s2Names).not.toContain('theme')
  169. })
  170. it('contribution rows ride the same query prefix filter', async () => {
  171. const { command, source } = await bench()
  172. command.register(themeContribution())
  173. const names = (await source.candidates(proj('s1'), req('th'))).map(c => c.name)
  174. expect(names).toEqual(['theme'])
  175. })
  176. it('a contribution/host name collision fails loud', async () => {
  177. const { command, source } = await bench()
  178. command.register(themeContribution({ name: 'plan' }))
  179. await expect(source.candidates(proj('s1'), req(''))).rejects.toThrow('collides with a host command')
  180. })
  181. })
  182. describe('dispatch (menu column)', () => {
  183. it('contribution → opens the session popup with the open-time projection, no execute', async () => {
  184. const { command, source, mint, warm, executeCalls } = await bench()
  185. const options = vi.fn((_s: ClientSessionContext) => Promise.resolve([{ id: 'dark', label: 'Dark' }]))
  186. command.register(themeContribution({ ui: themeUi({ options }) }))
  187. const scope = mint('s1')
  188. await warm(proj('s1'))
  189. expect(menuPick(source, 'theme', proj('s1'))).toBe('handled')
  190. const popup = command.popupFor(scope.ctx)
  191. expect(popup.state.getSnapshot()).toMatchObject({ open: true, command: 'theme' })
  192. expect(options).toHaveBeenCalledExactlyOnceWith(proj('s1'), expect.any(AbortSignal))
  193. expect(executeCalls).toEqual([])
  194. })
  195. it('an unavailable contribution falls through to the host catalog', async () => {
  196. const { command, source, mint, warm } = await bench()
  197. command.register(themeContribution({ available: () => false }))
  198. const scope = mint('s1')
  199. await warm(proj('s1'))
  200. expect(menuPick(source, 'theme', proj('s1'))).toBeUndefined() // no host 'theme' either
  201. expect(command.popupFor(scope.ctx).state.getSnapshot().open).toBe(false)
  202. })
  203. it('host leadingInput → {claim} with token "/name " and hint; claiming never executes', async () => {
  204. const { source, warm, executeCalls } = await bench()
  205. await warm(proj('s1'))
  206. const outcome = menuPick(source, 'goal', proj('s1'))
  207. if (outcome === undefined || outcome === 'handled' || !('claim' in outcome)) throw new Error('expected claim')
  208. expect(outcome.claim.token).toBe('/goal ')
  209. expect(outcome.claim.hint).toBe('goal text')
  210. expect(executeCalls).toEqual([])
  211. })
  212. it('host bare → consume-token span guard on the session scope + detached execute', async () => {
  213. const { source, mint, warm, executeCalls } = await bench()
  214. const scope = mint('s1')
  215. const consumes: ConsumeTokenRequest[] = []
  216. scope.ctx.on('slash/input-consume-token', (r) => {
  217. consumes.push(r)
  218. return true
  219. })
  220. await warm(proj('s1'))
  221. expect(menuPick(source, 'plan', proj('s1'), 5)).toBe('handled')
  222. expect(consumes).toEqual([{ guard: { kind: 'span', span: { start: 0, end: 5, draftRev: 3 } } }])
  223. await Promise.resolve()
  224. expect(executeCalls).toEqual([{ sessionId: sid('s1'), line: '/plan' }])
  225. })
  226. it('a name the directory no longer serves → undefined (snapshot swapped between menu and pick)', async () => {
  227. const { source, warm } = await bench()
  228. await warm(proj('s1'))
  229. expect(menuPick(source, 'gone', proj('s1'))).toBeUndefined()
  230. })
  231. })
  232. describe('matchSpace (space column)', () => {
  233. it('answers undefined from a not-ready key (no waiting, no RPC)', async () => {
  234. const { source, listCalls } = await bench()
  235. expect(source.matchSpace!(proj('s1'), '/goal')).toBeUndefined()
  236. expect(listCalls).toEqual([])
  237. })
  238. it('hot leadingInput exact token → {claim}; the key axis is the session', async () => {
  239. const { source, warm } = await bench()
  240. await warm(proj('s2'))
  241. const outcome = source.matchSpace!(proj('s2'), '/attach')
  242. if (outcome === undefined || outcome === 'handled' || !('claim' in outcome)) throw new Error('expected claim')
  243. expect(outcome.claim.token).toBe('/attach ')
  244. // s1's key is still cold: the same token answers undefined there.
  245. expect(source.matchSpace!(proj('s1'), '/attach')).toBeUndefined()
  246. })
  247. it('bare kind and contribution names stay plain text', async () => {
  248. const { command, source, warm } = await bench()
  249. command.register(themeContribution())
  250. await warm(proj('s1'))
  251. expect(source.matchSpace!(proj('s1'), '/plan')).toBeUndefined()
  252. expect(source.matchSpace!(proj('s1'), '/theme')).toBeUndefined()
  253. })
  254. it('unknown token / non-slash token → undefined', async () => {
  255. const { source, warm } = await bench()
  256. await warm(proj('s1'))
  257. expect(source.matchSpace!(proj('s1'), '/nope')).toBeUndefined()
  258. expect(source.matchSpace!(proj('s1'), 'plan')).toBeUndefined()
  259. })
  260. })
  261. describe('matchEnter (enter column)', () => {
  262. const signal = () => new AbortController().signal
  263. it('strong-waits a cold key before adjudicating', async () => {
  264. let release!: (value: { commands: CommandDescriptor[] }) => void
  265. const { source } = await bench({
  266. commands: () => new Promise((resolve) => { release = resolve }),
  267. })
  268. const wait = source.matchEnter!(proj('s1'), '/goal args', signal())
  269. release({ commands: S1_CMDS })
  270. const outcome = await wait
  271. if (outcome === undefined || outcome === 'handled' || !('claim' in outcome)) throw new Error('expected claim')
  272. expect(outcome.claim.token).toBe('/goal ')
  273. })
  274. it('rejects when warmup fails (never a silent downgrade)', async () => {
  275. const { source } = await bench({
  276. commands: () => Promise.reject(new Error('warmup boom')),
  277. })
  278. await expect(source.matchEnter!(proj('s1'), '/goal', signal())).rejects.toThrow('warmup boom')
  279. })
  280. it('leadingInput claims args-tolerant (bare and with trailing text)', async () => {
  281. const { source, warm } = await bench()
  282. await warm(proj('s1'))
  283. for (const line of ['/goal', '/goal refactor the loop']) {
  284. const outcome = await source.matchEnter!(proj('s1'), line, signal())
  285. if (outcome === undefined || outcome === 'handled' || !('claim' in outcome)) throw new Error('expected claim')
  286. expect(outcome.claim.token).toBe('/goal ')
  287. }
  288. })
  289. it('bare host command executes detached with the bare-token consume guard', async () => {
  290. const { source, mint, warm, executeCalls } = await bench()
  291. const scope = mint('s1')
  292. const consumes: ConsumeTokenRequest[] = []
  293. scope.ctx.on('slash/input-consume-token', (r) => {
  294. consumes.push(r)
  295. return true
  296. })
  297. await warm(proj('s1'))
  298. await expect(source.matchEnter!(proj('s1'), '/plan', signal())).resolves.toBe('handled')
  299. expect(consumes).toEqual([{ guard: { kind: 'bare-token', token: '/plan' } }])
  300. await Promise.resolve()
  301. expect(executeCalls).toEqual([{ sessionId: sid('s1'), line: '/plan' }])
  302. })
  303. it('bare kind with trailing text → undefined and no RPC (default sink owns the line)', async () => {
  304. const { source, warm, executeCalls } = await bench()
  305. await warm(proj('s1'))
  306. await expect(source.matchEnter!(proj('s1'), '/plan now', signal())).resolves.toBeUndefined()
  307. expect(executeCalls).toEqual([])
  308. })
  309. it('contribution: bare token opens the popup without touching the directory; args → undefined', async () => {
  310. const { command, source, mint, listCalls } = await bench()
  311. command.register(themeContribution())
  312. const scope = mint('s1')
  313. await expect(source.matchEnter!(proj('s1'), '/theme', signal())).resolves.toBe('handled')
  314. expect(command.popupFor(scope.ctx).state.getSnapshot().open).toBe(true)
  315. expect(listCalls).toEqual([]) // contribution short-circuits ahead of ensureReady
  316. await expect(source.matchEnter!(proj('s1'), '/theme dark', signal())).resolves.toBeUndefined()
  317. })
  318. it('unknown name, bare "/", and non-slash lines → undefined', async () => {
  319. const { source, warm } = await bench()
  320. await warm(proj('s1'))
  321. await expect(source.matchEnter!(proj('s1'), '/nope', signal())).resolves.toBeUndefined()
  322. await expect(source.matchEnter!(proj('s1'), '/', signal())).resolves.toBeUndefined()
  323. await expect(source.matchEnter!(proj('s1'), 'plain text', signal())).resolves.toBeUndefined()
  324. })
  325. })
  326. describe('execute payload', () => {
  327. it('claim.submit addresses the session and maps the detached result', async () => {
  328. const { source, warm, executeCalls } = await bench({
  329. execute: () => Promise.resolve({ matched: true, result: { kind: 'success', text: 'goal set' } }),
  330. })
  331. await warm(proj('s1'))
  332. const outcome = source.matchSpace!(proj('s1'), '/goal')
  333. if (outcome === undefined || outcome === 'handled' || !('claim' in outcome)) throw new Error('expected claim')
  334. const settled = await outcome.claim.submit('ship it', new Context())
  335. expect(executeCalls).toEqual([{ sessionId: sid('s1'), line: '/goal ship it' }])
  336. expect(settled).toEqual({ kind: 'success', text: 'goal set' })
  337. })
  338. it('maps matched:false to an error outcome and a matched bare result to success', async () => {
  339. const claimOf = async (opts: BenchOptions) => {
  340. const b = await bench(opts)
  341. await b.warm(proj('s1'))
  342. const outcome = b.source.matchSpace!(proj('s1'), '/goal')
  343. if (outcome === undefined || outcome === 'handled' || !('claim' in outcome)) throw new Error('expected claim')
  344. return outcome.claim
  345. }
  346. const first = await claimOf({ execute: () => Promise.resolve({ matched: false }) })
  347. const bad = await first.submit('x', new Context())
  348. expect(bad.kind).toBe('error')
  349. const second = await claimOf({ execute: () => Promise.resolve({ matched: true }) })
  350. await expect(second.submit('', new Context())).resolves.toEqual({ kind: 'success' })
  351. })
  352. })
  353. describe('detached result notices', () => {
  354. const flush = () => new Promise(resolve => setTimeout(resolve, 0))
  355. it('success text → info; error result → error; rejection → error, all on the triggering session', async () => {
  356. let mode: 'info' | 'error' | 'reject' = 'info'
  357. const { source, mint, warm, notices } = await bench({
  358. execute: () => {
  359. if (mode === 'reject') return Promise.reject(new Error('network down'))
  360. return Promise.resolve({
  361. matched: true,
  362. result: mode === 'info'
  363. ? { kind: 'success' as const, text: 'compacted 12 messages' }
  364. : { kind: 'error' as const, text: 'plan mode refused' },
  365. })
  366. },
  367. })
  368. mint('s1')
  369. await warm(proj('s1'))
  370. menuPick(source, 'plan', proj('s1'))
  371. await flush()
  372. expect(notices).toEqual([{ scope: sid('s1'), level: 'info', text: 'compacted 12 messages' }])
  373. notices.length = 0
  374. mode = 'error'
  375. await source.matchEnter!(proj('s1'), '/plan', new AbortController().signal)
  376. await flush()
  377. expect(notices).toEqual([{ scope: sid('s1'), level: 'error', text: 'plan mode refused' }])
  378. notices.length = 0
  379. mode = 'reject'
  380. menuPick(source, 'plan', proj('s1'))
  381. await flush()
  382. expect(notices).toEqual([{ scope: sid('s1'), level: 'error', text: 'network down' }])
  383. })
  384. it('success without text stays silent; a torn-down scope drops the notice', async () => {
  385. const { source, warm, notices } = await bench({
  386. execute: () => Promise.resolve({ matched: true, result: { kind: 'success' as const, text: 'orphan' } }),
  387. })
  388. await warm(proj('ghost')) // never minted: scopeFor misses
  389. menuPick(source, 'plan', proj('ghost'))
  390. await flush()
  391. expect(notices).toEqual([])
  392. })
  393. })
  394. describe('register (contribution face)', () => {
  395. it('duplicate registration throws; the disposer frees the name', async () => {
  396. const { command } = await bench()
  397. const dispose = command.register(themeContribution())
  398. expect(() => command.register(themeContribution())).toThrow('duplicate contribution')
  399. dispose()
  400. command.register(themeContribution())()
  401. })
  402. })
  403. describe('popupFor', () => {
  404. it('resolves lazily per session; a foreign session gets its own controller; unscoped ctx throws', async () => {
  405. const { ctx, command, mint } = await bench()
  406. const a = mint('s1')
  407. const first = command.popupFor(a.ctx)
  408. expect(command.popupFor(a.ctx)).toBe(first)
  409. expect(command.popupFor(mint('s2').ctx)).not.toBe(first)
  410. expect(() => command.popupFor(ctx)).toThrow('requires a session scope')
  411. })
  412. it('a successful select dispatches the scoped consume-token and fires the bound composer focus', async () => {
  413. const { command, source, mint } = await bench()
  414. const onSelect = vi.fn()
  415. command.register(themeContribution({ ui: themeUi({ onSelect }) }))
  416. const scope = mint('s1')
  417. const consumes: ConsumeTokenRequest[] = []
  418. scope.ctx.on('slash/input-consume-token', (r) => {
  419. consumes.push(r)
  420. return true
  421. })
  422. const focus = vi.fn()
  423. command.bindComposerFocus(sid('s1'), focus)
  424. expect(menuPick(source, 'theme', proj('s1'), 6)).toBe('handled')
  425. const popup = command.popupFor(scope.ctx)
  426. await Promise.resolve() // options land
  427. await popup.select(0)
  428. expect(onSelect).toHaveBeenCalledExactlyOnceWith({ id: 'dark', label: 'Dark' } satisfies SelectOption, proj('s1'))
  429. expect(consumes).toEqual([{ guard: { kind: 'span', span: { start: 0, end: 6, draftRev: 3 } } }])
  430. expect(focus).toHaveBeenCalledTimes(1)
  431. })
  432. it('the enter path opens with the bare-token guard', async () => {
  433. const { command, source, mint } = await bench()
  434. command.register(themeContribution())
  435. const scope = mint('s1')
  436. const consumes: ConsumeTokenRequest[] = []
  437. scope.ctx.on('slash/input-consume-token', (r) => {
  438. consumes.push(r)
  439. return true
  440. })
  441. await source.matchEnter!(proj('s1'), '/theme', new AbortController().signal)
  442. const popup = command.popupFor(scope.ctx)
  443. await Promise.resolve()
  444. await popup.select(0)
  445. expect(consumes).toEqual([{ guard: { kind: 'bare-token', token: '/theme' } }])
  446. })
  447. it('the scope disposer disposes the controller and a re-mint resolves fresh', async () => {
  448. const { command, source, mint } = await bench()
  449. command.register(themeContribution())
  450. const scope = mint('s1')
  451. await source.matchEnter!(proj('s1'), '/theme', new AbortController().signal)
  452. const popup = command.popupFor(scope.ctx)
  453. expect(popup.state.getSnapshot().open).toBe(true)
  454. await scope.fiber.dispose()
  455. expect(popup.state.getSnapshot().open).toBe(false)
  456. expect(command.popupFor(mint('s1').ctx)).not.toBe(popup)
  457. })
  458. })
  459. describe('directory invalidation events', () => {
  460. it('commands/changed repulls in the background while the old snapshot serves', async () => {
  461. let round = 0
  462. const { ctx, source, warm } = await bench({
  463. commands: () => {
  464. round += 1
  465. return Promise.resolve({
  466. commands: round === 1
  467. ? S1_CMDS
  468. : [{ name: 'fresh', description: '', input: { hint: 'h' } }],
  469. })
  470. },
  471. })
  472. await warm(proj('s1'))
  473. ctx.emit('commands/changed')
  474. await new Promise(resolve => setTimeout(resolve, 0))
  475. expect(source.matchSpace!(proj('s1'), '/fresh')).not.toBeUndefined()
  476. expect(source.matchSpace!(proj('s1'), '/goal')).toBeUndefined()
  477. })
  478. it('connection/reset hard-drops every session key until its rewarm lands', async () => {
  479. let block = false
  480. let release!: (value: { commands: CommandDescriptor[] }) => void
  481. const { ctx, source, warm } = await bench({
  482. commands: () => (block
  483. ? new Promise((resolve) => { release = resolve })
  484. : Promise.resolve({ commands: S2_CMDS })),
  485. })
  486. await warm(proj('s2'))
  487. expect(source.matchSpace!(proj('s2'), '/attach')).not.toBeUndefined()
  488. block = true
  489. ctx.emit('connection/reset')
  490. // Hard reset: silent until the rewarm lands.
  491. expect(source.matchSpace!(proj('s2'), '/attach')).toBeUndefined()
  492. release({ commands: S2_CMDS })
  493. await new Promise(resolve => setTimeout(resolve, 0))
  494. expect(source.matchSpace!(proj('s2'), '/attach')).not.toBeUndefined()
  495. })
  496. })