runtime.client.spec.tsx 39 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862
  1. // @vitest-environment jsdom
  2. /**
  3. * SlotTestRuntime behavior: root declaration + rendering, session
  4. * add/update/switch/remove through the real renderer, shared store identity
  5. * and scope pruning, feature mount/dispose cascade, and runtime disposal
  6. * idempotence. All through the production SlotRegistry + createSlotRenderer
  7. * stack — this suite is the fixture the migrated feature specs rely on.
  8. */
  9. import { afterEach, describe, expect, it, vi } from 'vitest'
  10. import { Context } from '@deepseek-ai/cordis'
  11. import { stubSettingsScope } from '../src/settings-scope.ts'
  12. import { act, cleanup } from '@testing-library/react'
  13. import { useSyncExternalStore } from 'react'
  14. import { createSnapshotStore, defineStore } from '@deepseek-ai/dsh-client-store'
  15. import { createScope, type SessionReference } from '@deepseek-ai/dsh-api-session-controller/client'
  16. import type { WorkspaceId } from '@deepseek-ai/dsh-api-workspace-controller/client'
  17. import type { SessionId } from '@deepseek-ai/dsh-session/types'
  18. import type {
  19. PropsRenderSlots, SessionStandardProps, SlotRendererHost,
  20. } from '@deepseek-ai/dsh-client-ui-slots'
  21. import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
  22. declare module '@deepseek-ai/dsh-client-ui-slots' {
  23. interface SlotMap {
  24. 'trt.panel': { kind: 'single'; scope: 'root'; owner: { label?: string } }
  25. 'trt.chat': { kind: 'single'; scope: 'session' }
  26. 'trt.other-chat': { kind: 'single'; scope: 'session' }
  27. 'trt.maybe': { kind: 'single'; scope: 'session-maybe' }
  28. 'trt.rows': { kind: 'list'; scope: 'root' }
  29. 'trt.rows.hole': { kind: 'single'; scope: 'root' }
  30. }
  31. }
  32. afterEach(cleanup)
  33. type FrameProps = PropsRenderSlots<'trt.panel' | 'trt.chat' | 'trt.rows'> & { reference: SessionReference | undefined }
  34. /** Root frame declaring all three suite slots (render sites for each kind). */
  35. function Frame({ renderSlot, SessionProvider, reference }: FrameProps) {
  36. return (
  37. <>
  38. {renderSlot('trt.panel', { label: 'from-owner' }, { fallback: <i>no panel</i> })}
  39. <SessionProvider session={reference} empty={() => <i>no session</i>}>
  40. {renderSlot('trt.chat', {})}
  41. </SessionProvider>
  42. {renderSlot('trt.rows', {})}
  43. </>
  44. )
  45. }
  46. const CHILDREN = {
  47. 'trt.panel': { kind: 'single', scope: 'root' },
  48. 'trt.chat': { kind: 'single', scope: 'session' },
  49. 'trt.rows': { kind: 'list', scope: 'root' },
  50. } as const
  51. async function runtimeWithFrame(reference = createSnapshotStore<SessionReference | undefined>(undefined)) {
  52. const runtime = await SlotTestRuntime.create()
  53. const subscribe = reference.subscribe.bind(reference)
  54. const getSnapshot = reference.getSnapshot.bind(reference)
  55. await runtime.root.declare(CHILDREN, (props) => {
  56. const bound = useSyncExternalStore(subscribe, getSnapshot)
  57. return <Frame {...props} reference={bound} />
  58. })
  59. return runtime
  60. }
  61. describe('root declaration and rendering', () => {
  62. it('renders declared slots through the real renderer: fallback, then a live registration, then unload', async () => {
  63. const runtime = await runtimeWithFrame()
  64. const view = runtime.renderRoot()
  65. expect(view.container.textContent).toContain('no panel')
  66. let dispose = (): void => {}
  67. await runtime.flush() // no-op guard: flush outside mutations is safe
  68. await (async () => {
  69. dispose = runtime.slots.register(
  70. { name: 'trt.panel' },
  71. ({ label }: { label?: string }) => <b>panel:{label}</b>)
  72. await runtime.flush()
  73. })()
  74. expect(view.container.textContent).toContain('panel:from-owner')
  75. dispose()
  76. await runtime.flush()
  77. expect(view.container.textContent).toContain('no panel')
  78. await runtime.dispose()
  79. })
  80. it('fails loud when rendering with no root declaration (production boot-order check)', async () => {
  81. const runtime = await SlotTestRuntime.create()
  82. expect(() => runtime.renderRoot()).toThrow(/'root' has no registration/)
  83. await runtime.dispose()
  84. })
  85. })
  86. describe('sessions', () => {
  87. it('drives SessionProvider with explicit references and live snapshot updates', async () => {
  88. const reference = createSnapshotStore<SessionReference | undefined>(undefined)
  89. const runtime = await runtimeWithFrame(reference)
  90. runtime.slots.register({ name: 'trt.chat' }, (props: SessionStandardProps) => {
  91. const running = props.useSession(s => s.running)
  92. return <span>chat:{props.sessionId}:{String(running)}</span>
  93. })
  94. const view = runtime.renderRoot()
  95. expect(view.container.textContent).toContain('no session')
  96. await runtime.sessions.add({ id: 's1' })
  97. using first = runtime.sessions.retain('s1' as SessionId)
  98. await first.ready
  99. act(() => { reference.set(first) })
  100. expect(view.container.textContent).toContain('chat:s1:false')
  101. await runtime.sessions.updateSessionSnapshot('s1', (draft) => { draft.running = true })
  102. expect(view.container.textContent).toContain('chat:s1:true')
  103. await runtime.sessions.add({ id: 's2' })
  104. using second = runtime.sessions.retain('s2' as SessionId)
  105. await second.ready
  106. act(() => { reference.set(second) })
  107. expect(view.container.textContent).toContain('chat:s2:false')
  108. act(() => { reference.set(undefined) })
  109. expect(view.container.textContent).toContain('no session')
  110. act(() => { reference.set(first) })
  111. expect(view.container.textContent).toContain('chat:s1:true')
  112. await runtime.dispose()
  113. })
  114. it('adds catalog entries without retaining them and rejects unknown mutation targets', async () => {
  115. const runtime = await runtimeWithFrame()
  116. await runtime.sessions.add({ id: 's1' })
  117. await runtime.sessions.add({ id: 's2' })
  118. expect(runtime.sessions.binding('s1')).toBeUndefined()
  119. expect(runtime.sessions.binding('s2')).toBeUndefined()
  120. expect(runtime.sessions.list.getSnapshot().ids).toEqual(['s1', 's2'])
  121. await expect(runtime.sessions.add({ id: 's1' })).rejects.toThrow(/already added/)
  122. expect(() => runtime.sessions.retain('ghost' as SessionId)).toThrow(/not added/)
  123. await expect(runtime.sessions.updateSessionSnapshot('ghost', () => {})).rejects.toThrow(/not added/)
  124. await expect(runtime.sessions.remove('ghost')).rejects.toThrow(/not added/)
  125. expect(() => runtime.sessions.behavior('ghost')).toThrow(/not added/)
  126. await runtime.dispose()
  127. })
  128. it('mints REAL-tag scopes lazily and resolves them through the production scopeOf; bindings expose the behavior face', async () => {
  129. const runtime = await runtimeWithFrame()
  130. const prompt = vi.fn()
  131. await runtime.sessions.add({ id: 's1', session: { prompt } })
  132. using reference = runtime.sessions.retain('s1' as SessionId)
  133. await reference.ready
  134. expect(reference.binding.sessionId).toBe('s1')
  135. expect(runtime.sessions.scope('ghost')).toBeUndefined()
  136. expect(runtime.sessions.binding('ghost')).toBeUndefined()
  137. const scope = runtime.sessions.scope('s1')!
  138. expect(runtime.sessions.scope('s1')).toBe(scope) // stable per session
  139. expect(runtime.sessions.scopeOf(scope)).toBe('s1')
  140. expect(runtime.sessions.scopeOf(runtime.ctx)).toBeUndefined()
  141. // sessionOf resolves the behavior face off the scope tag.
  142. expect(runtime.sessions.sessionOf(scope)).toBe(runtime.sessions.behavior('s1'))
  143. expect(runtime.sessions.sessionOf(runtime.ctx)).toBeUndefined()
  144. const foreign = createScope(runtime.ctx, 's1' as SessionId)
  145. expect(runtime.sessions.sessionOf(foreign.ctx)).toBeUndefined()
  146. await foreign.fiber.dispose()
  147. const binding = runtime.sessions.binding('s1')!
  148. expect(binding.sessionId).toBe('s1')
  149. expect(binding.ctx).toBe(scope)
  150. await binding.session.prompt([], 'queue')
  151. expect(prompt).toHaveBeenCalledOnce()
  152. expect(runtime.sessions.behavior('s1')).toBe(binding.session)
  153. expect(binding.session.getSnapshot().sessionId).toBe('s1')
  154. // A scoped service resolves through the scope ctx (scope-addressed pattern).
  155. runtime.ctx.provide('probe', { hello: 'world' })
  156. expect(scope.get('probe')).toEqual({ hello: 'world' })
  157. await runtime.dispose()
  158. })
  159. it('accepts explicit addresses without a catalog and keeps them independent of references', async () => {
  160. const runtime = await runtimeWithFrame()
  161. await runtime.sessions.add({ id: 's1' })
  162. await runtime.sessions.add({ id: 's2' })
  163. const address = {
  164. parentSessionId: 's2' as SessionId, childSessionId: 's1' as SessionId, mode: 'continuable' as const,
  165. }
  166. using reference = runtime.sessions.retain(address)
  167. await reference.ready
  168. expect(reference.sessionId).toBe('s1')
  169. expect(runtime.sessions.subagentAddress('s1' as SessionId)).toEqual(address)
  170. expect(runtime.sessions.subagentAddress('s2' as SessionId)).toBeUndefined()
  171. await runtime.sessions.updateSummary('s1', { displayTitle: 'renamed', running: true })
  172. expect(runtime.sessions.list.getSnapshot().byId['s1' as SessionId])
  173. .toMatchObject({ displayTitle: 'renamed', running: true })
  174. runtime.sessions.setSubagentCatalogOpen('s2' as SessionId, true)
  175. await runtime.sessions.refreshSubagents('s2' as SessionId)
  176. reference.release()
  177. expect(runtime.sessions.binding('s1')).toBeUndefined()
  178. expect(runtime.sessions.subagentAddress('s1' as SessionId)).toEqual(address)
  179. await expect(runtime.sessions.fork({
  180. sessionId: 's1' as SessionId, atSeq: 7, increaseTitle: true,
  181. })).resolves.toBe('s1')
  182. expect(runtime.sessions.calls).toEqual([
  183. { method: 'setSubagentCatalogOpen', args: ['s2', true] },
  184. { method: 'refreshSubagents', args: ['s2'] },
  185. { method: 'fork', args: [{ sessionId: 's1', atSeq: 7, increaseTitle: true }] },
  186. ])
  187. await runtime.dispose()
  188. })
  189. it('resolves catalog addresses without retaining their parent', async () => {
  190. const runtime = await runtimeWithFrame()
  191. const parentId = 'parent' as SessionId
  192. await runtime.sessions.add({ id: 'child' })
  193. runtime.sessions.list.update((draft) => {
  194. draft.subagentsByParent = {
  195. [parentId]: {
  196. state: 'ready', error: null, parentAvailable: true,
  197. entries: [
  198. { kind: 'child', id: 'other' as SessionId, mode: 'one-shot', activity: 'inactive', hasChildren: false },
  199. { kind: 'child', id: 'child' as SessionId, mode: 'continuable', label: 'Child', activity: 'inactive', hasChildren: false },
  200. ],
  201. },
  202. }
  203. })
  204. expect(runtime.sessions.subagentAddress('child' as SessionId)).toEqual({
  205. parentSessionId: parentId, childSessionId: 'child', mode: 'continuable',
  206. })
  207. expect(runtime.sessions.subagentAddress('missing' as SessionId)).toBeUndefined()
  208. await runtime.dispose()
  209. })
  210. it('tracks repeated references and releases callback and Context-owned references', async () => {
  211. const runtime = await runtimeWithFrame()
  212. const id = await runtime.sessions.add({ id: 'owned' })
  213. const info = runtime.sessions.retainInfo(id)
  214. expect(runtime.sessions.retainInfo(id)).toBe(info)
  215. const first = runtime.sessions.retain(id, { source: 'testOperation' })
  216. const second = runtime.sessions.retain(id, { source: 'testOperation' })
  217. const scope = first.binding.ctx
  218. await Promise.all([first.ready, second.ready])
  219. expect(info.getSnapshot()).toEqual({ referenceCount: 2, retainedBy: { testOperation: 2 } })
  220. expect(runtime.sessions.list.getSnapshot().byId[id]?.retainedBy).toEqual({ testOperation: 2 })
  221. first.release()
  222. expect(info.getSnapshot()).toEqual({ referenceCount: 1, retainedBy: { testOperation: 1 } })
  223. second.release()
  224. await scope.fiber.dispose()
  225. expect(info.getSnapshot()).toEqual({ referenceCount: 0, retainedBy: {} })
  226. await expect(runtime.sessions.using(id, { source: 'testOperation' }, reference => reference.sessionId))
  227. .resolves.toBe(id)
  228. const failure = new Error('operation failed')
  229. await expect(runtime.sessions.using(id, { source: 'testOperation' }, () => { throw failure }))
  230. .rejects.toBe(failure)
  231. const owner = new Context()
  232. const owned = runtime.sessions.retainFor(owner, id)
  233. await owned.ready
  234. const ownedScope = owned.binding.ctx
  235. await owner.fiber.dispose()
  236. await ownedScope.fiber.dispose()
  237. expect(info.getSnapshot()).toEqual({ referenceCount: 0, retainedBy: {} })
  238. await runtime.dispose()
  239. })
  240. it('releases a retained fixture when its Context owner rejects registration', async () => {
  241. const runtime = await runtimeWithFrame()
  242. const id = await runtime.sessions.add({ id: 'owner-failure' })
  243. const owner = new Context()
  244. const failure = new Error('owner rejected effect')
  245. vi.spyOn(owner, 'effect').mockImplementation(() => { throw failure })
  246. expect(() => runtime.sessions.retainFor(owner, id)).toThrow(failure)
  247. expect(runtime.sessions.retainInfo(id).getSnapshot()).toEqual({ referenceCount: 0, retainedBy: {} })
  248. await runtime.dispose()
  249. })
  250. it('releases a reference when readiness signal composition rejects it', async () => {
  251. const runtime = await runtimeWithFrame()
  252. const id = await runtime.sessions.add({ id: 'bad-signal' })
  253. const malformed = { throwIfAborted: () => {} } as AbortSignal
  254. expect(() => runtime.sessions.retain(id, {
  255. source: 'testOperation', signal: malformed,
  256. })).toThrow(TypeError)
  257. expect(runtime.sessions.retainInfo(id).getSnapshot()).toEqual({
  258. referenceCount: 0, retainedBy: {},
  259. })
  260. await runtime.dispose()
  261. })
  262. it('mirrors acquisition cancellation and generation disposal races', async () => {
  263. const runtime = await runtimeWithFrame()
  264. const blocked = Promise.withResolvers<undefined>()
  265. const cancelledId = await runtime.sessions.add({
  266. id: 'cancelled', initialOpen: () => blocked.promise,
  267. })
  268. const reason = new Error('cancelled while retaining')
  269. const controller = new AbortController()
  270. const info = runtime.sessions.retainInfo(cancelledId)
  271. const off = info.subscribe(() => {
  272. if (info.getSnapshot().referenceCount > 0) controller.abort(reason)
  273. })
  274. const cancelled = runtime.sessions.retain(cancelledId, {
  275. source: 'testOperation', signal: controller.signal,
  276. })
  277. await expect(cancelled.ready).rejects.toBe(reason)
  278. cancelled.release()
  279. blocked.resolve(undefined)
  280. off()
  281. const opening = Promise.withResolvers<undefined>()
  282. const disposedId = await runtime.sessions.add({ id: 'disposed', initialOpen: () => opening.promise })
  283. const disposed = runtime.sessions.retain(disposedId)
  284. const binding = disposed.binding
  285. const scopeDisposal = binding.ctx.fiber.dispose()
  286. opening.resolve(undefined)
  287. await scopeDisposal
  288. await expect(disposed.ready).rejects.toThrow('is released')
  289. disposed.release()
  290. await runtime.dispose()
  291. })
  292. it('propagates synchronous opening failures and reports scope-disposal failures', async () => {
  293. const runtime = await runtimeWithFrame()
  294. const openingFailure = new Error('opening failed')
  295. const failedId = await runtime.sessions.add({
  296. id: 'failed-open', initialOpen: () => { throw openingFailure },
  297. })
  298. const failed = runtime.sessions.retain(failedId)
  299. const failedScope = failed.binding.ctx
  300. await expect(failed.ready).rejects.toBe(openingFailure)
  301. failed.release()
  302. await failedScope.fiber.dispose()
  303. const disposalId = await runtime.sessions.add({ id: 'failed-disposal' })
  304. const reference = runtime.sessions.retain(disposalId)
  305. await reference.ready
  306. const scope = reference.binding.ctx
  307. const dispose = scope.fiber.dispose.bind(scope.fiber)
  308. const disposalFailure = new Error('scope disposal failed')
  309. vi.spyOn(scope.fiber, 'dispose').mockRejectedValueOnce(disposalFailure)
  310. const warning = vi.spyOn(runtime.ctx.logger, 'warn').mockImplementation(() => undefined)
  311. reference.release()
  312. await vi.waitFor(() => {
  313. expect(warning).toHaveBeenCalledWith('test Session scope disposal failed:', disposalFailure)
  314. })
  315. await dispose()
  316. await runtime.dispose()
  317. })
  318. it('invalidates live generations when the runtime Context ends', async () => {
  319. const runtime = await runtimeWithFrame()
  320. const id = await runtime.sessions.add({ id: 'live-at-root-disposal' })
  321. const reference = runtime.sessions.retain(id)
  322. await reference.ready
  323. const info = runtime.sessions.retainInfo(id)
  324. await runtime.ctx.fiber.dispose()
  325. expect(info.getSnapshot()).toEqual({ referenceCount: 0, retainedBy: {} })
  326. expect(() => reference.binding).toThrow('is released')
  327. expect(() => runtime.sessions.retain(id)).toThrow('disposed')
  328. })
  329. it('keeps a same-id replacement when stale generation cleanup arrives', async () => {
  330. const runtime = await runtimeWithFrame()
  331. const id = await runtime.sessions.add({ id: 'replacement' })
  332. const old = runtime.sessions.retain(id)
  333. await old.ready
  334. const oldBinding = old.binding
  335. const generations = (runtime.sessions as unknown as {
  336. generations: Map<SessionId, unknown>
  337. }).generations
  338. generations.delete(id)
  339. const replacement = runtime.sessions.retain(id)
  340. await replacement.ready
  341. await oldBinding.ctx.fiber.dispose()
  342. expect(runtime.sessions.binding(id)).toBe(replacement.binding)
  343. old.release()
  344. replacement.release()
  345. await runtime.dispose()
  346. })
  347. it('answers search with an empty page until a scenario declares hits, recording every call', async () => {
  348. const runtime = await runtimeWithFrame()
  349. await runtime.sessions.add({ id: 's1' })
  350. const signal = new AbortController().signal
  351. expect(runtime.sessions.searchResultLimit).toBeGreaterThan(0)
  352. await expect(runtime.sessions.search('marker', signal))
  353. .resolves.toEqual({ ok: true, value: { items: [], hasMore: false } })
  354. runtime.sessions.stubSearch(query => ({
  355. items: [{ sessionId: 's1' as SessionId, snippet: `hit: ${query}` }],
  356. hasMore: true,
  357. }))
  358. await expect(runtime.sessions.search('marker', signal)).resolves.toEqual({
  359. ok: true,
  360. value: { items: [{ sessionId: 's1', snippet: 'hit: marker' }], hasMore: true },
  361. })
  362. expect(runtime.sessions.calls).toEqual([
  363. { method: 'search', args: ['marker', signal] },
  364. { method: 'search', args: ['marker', signal] },
  365. ])
  366. await runtime.dispose()
  367. })
  368. })
  369. describe('stores', () => {
  370. const createSuiteStore = () => defineStore({
  371. init: () => ({ note: '' }),
  372. persist: 'trt.store',
  373. actions: { setNote: (d, note: string) => { d.note = note } },
  374. })
  375. it('resolves per-session instances via the host face: shared identity, isolation, action-driven re-render', async () => {
  376. const reference = createSnapshotStore<SessionReference | undefined>(undefined)
  377. const runtime = await runtimeWithFrame(reference)
  378. const handle = createSuiteStore()
  379. runtime.slots.register(
  380. { name: 'trt.chat', store: handle },
  381. (props: SessionStandardProps & { useStore: <S>(sel: (s: { note: string }) => S) => S }) =>
  382. <span>note:{props.useStore(s => s.note)}</span>)
  383. const view = runtime.renderRoot()
  384. await runtime.sessions.add({ id: 's1' })
  385. using first = runtime.sessions.retain('s1' as SessionId)
  386. await first.ready
  387. act(() => { reference.set(first) })
  388. expect(() => runtime.storeOf('trt.panel')).toThrow(/no registration/)
  389. const store = runtime.storeOf('trt.chat', first)
  390. await runtime.flush()
  391. ;(store.actions['setNote'] as (note: string) => void)('hello')
  392. await runtime.flush()
  393. expect(view.container.textContent).toContain('note:hello')
  394. expect(runtime.storeOf('trt.chat', first)).toBe(store) // cached per scope key
  395. await runtime.sessions.add({ id: 's2' })
  396. using second = runtime.sessions.retain('s2' as SessionId)
  397. await second.ready
  398. act(() => { reference.set(second) })
  399. const other = runtime.storeOf('trt.chat', second)
  400. expect(other).not.toBe(store)
  401. expect(other.getSnapshot()).toEqual({ note: '' })
  402. await runtime.dispose()
  403. })
  404. it('storeOf guards: before renderRoot, and for storeless entries', async () => {
  405. const runtime = await runtimeWithFrame()
  406. runtime.slots.register({ name: 'trt.panel' }, () => null)
  407. runtime.slots.register({ name: 'trt.chat', store: createSuiteStore() }, () => null)
  408. expect(() => runtime.storeOf('trt.panel')).toThrow(/before renderRoot/)
  409. runtime.renderRoot()
  410. expect(() => runtime.storeOf('trt.panel')).toThrow(/declares no store/)
  411. const host = (runtime as unknown as { host: SlotRendererHost }).host
  412. const adapter = host.scope('session')!
  413. const bindingSource = vi.spyOn(adapter, 'bindingSource').mockReturnValue(createSnapshotStore({
  414. key: undefined, hooks: {}, keyedHooks: {}, props: {},
  415. }))
  416. expect(() => runtime.storeOf('trt.chat', { sessionId: 'missing' as SessionId } as SessionReference))
  417. .toThrow(/no live Session binding/)
  418. bindingSource.mockRestore()
  419. await runtime.sessions.add({ id: 'released' })
  420. const released = runtime.sessions.retain('released' as SessionId)
  421. released.release()
  422. expect(() => runtime.storeOf('trt.chat', released)).toThrow(/is released/)
  423. await runtime.dispose()
  424. })
  425. it('removal and reference release preserve the Session Store persistence', async () => {
  426. const reference = createSnapshotStore<SessionReference | undefined>(undefined)
  427. const runtime = await runtimeWithFrame(reference)
  428. const handle = createSuiteStore()
  429. runtime.slots.register({ name: 'trt.chat', store: handle }, () => null)
  430. runtime.renderRoot()
  431. await runtime.sessions.add({ id: 's1' })
  432. using first = runtime.sessions.retain('s1' as SessionId)
  433. await first.ready
  434. act(() => { reference.set(first) })
  435. const doomed = runtime.storeOf('trt.chat', first)
  436. ;(doomed.actions['setNote'] as (note: string) => void)('buried')
  437. expect(localStorage.getItem('trt.store.s1')).not.toBeNull()
  438. await runtime.sessions.remove('s1')
  439. expect(localStorage.getItem('trt.store.s1')).not.toBeNull()
  440. expect(runtime.sessions.list.getSnapshot().ids).toEqual([])
  441. expect(runtime.sessions.binding('s1')?.session.getSnapshot().removed).toBe(true)
  442. expect(runtime.storeOf('trt.chat', first)).toBe(doomed)
  443. await act(async () => { reference.set(undefined); first.release(); await runtime.flush() })
  444. expect(localStorage.getItem('trt.store.s1')).not.toBeNull()
  445. expect(runtime.sessions.binding('s1')).toBeUndefined()
  446. await runtime.sessions.add({ id: 's1' })
  447. using replacement = runtime.sessions.retain('s1' as SessionId)
  448. await replacement.ready
  449. act(() => { reference.set(replacement) })
  450. const reborn = runtime.storeOf('trt.chat', replacement)
  451. expect(reborn).not.toBe(doomed)
  452. expect(reborn.getSnapshot()).toEqual({ note: 'buried' })
  453. await runtime.dispose()
  454. })
  455. it('removing catalog rows preserves an explicitly retained scope until release', async () => {
  456. const runtime = await runtimeWithFrame()
  457. await runtime.sessions.add({ id: 's1' })
  458. await runtime.sessions.add({ id: 's2' })
  459. using reference = runtime.sessions.retain('s1' as SessionId)
  460. await reference.ready
  461. const scope = runtime.sessions.scope('s1')!
  462. await runtime.sessions.remove('s2')
  463. expect(runtime.sessions.binding('s1')).toBe(reference.binding)
  464. await runtime.sessions.remove('s1')
  465. expect(scope.fiber.uid).not.toBeNull()
  466. expect(runtime.sessions.binding('s1')).toBe(reference.binding)
  467. await act(async () => { reference.release(); await runtime.flush() })
  468. expect(scope.fiber.uid).toBeNull()
  469. expect(runtime.sessions.binding('s1')).toBeUndefined()
  470. await runtime.dispose()
  471. })
  472. })
  473. describe('workspaces', () => {
  474. it('feeds the renderer root source from the Workspace Controller snapshot', async () => {
  475. const runtime = await runtimeWithFrame()
  476. runtime.slots.register(
  477. { name: 'trt.panel' },
  478. (props: { useWorkspaces: <S>(sel: (s: { phase: string }) => S) => S }) =>
  479. <span>ws:{props.useWorkspaces(s => s.phase)}</span>)
  480. const view = runtime.renderRoot()
  481. expect(view.container.textContent).toContain('ws:ready')
  482. await runtime.workspaces.update((draft) => { draft.phase = 'pending' })
  483. expect(view.container.textContent).toContain('ws:pending')
  484. await runtime.dispose()
  485. })
  486. })
  487. describe('feature mount and disposal', () => {
  488. it('mounts a plugin on a real fiber; dispose() cascades entries, declared children, and services', async () => {
  489. const runtime = await runtimeWithFrame()
  490. runtime.ctx.provide('layout', { openDetails: vi.fn() })
  491. const feature = await runtime.mount({
  492. inject: ['slots', 'layout'],
  493. apply: (ctx: typeof runtime.ctx) => {
  494. ctx.provide('feature-service', { ok: true })
  495. ctx.slots.register({
  496. name: 'trt.rows',
  497. id: 'row-1',
  498. children: { 'trt.rows.hole': { kind: 'single', scope: 'root' } },
  499. } as never, ((props: { renderSlot: (key: string, owner: object) => unknown }) =>
  500. <div data-testid="row">{props.renderSlot('trt.rows.hole', {}) as React.ReactNode}</div>) as never)
  501. },
  502. })
  503. const view = runtime.renderRoot()
  504. expect(view.getByTestId('row')).toBeTruthy()
  505. expect(runtime.ctx.get('feature-service')).toEqual({ ok: true })
  506. expect(runtime.slots.entries('trt.rows')).toHaveLength(1)
  507. await feature.dispose()
  508. await feature.dispose() // idempotent
  509. expect(runtime.slots.entries('trt.rows')).toHaveLength(0)
  510. expect(runtime.slots.spec('trt.rows.hole')).toBeUndefined()
  511. expect(runtime.ctx.get('feature-service')).toBeUndefined()
  512. expect(view.queryByTestId('row')).toBeNull()
  513. await runtime.dispose()
  514. })
  515. it('mount fails loud on missing services instead of suspending forever', async () => {
  516. const runtime = await runtimeWithFrame()
  517. await expect(runtime.mount({ inject: ['slots', 'absent-service'], apply: () => {} }))
  518. .rejects.toThrow(/missing service\(s\) absent-service/)
  519. await runtime.dispose()
  520. })
  521. it('runtime dispose is idempotent, unmounts views, disposes mounted features, and clears persisted state', async () => {
  522. const runtime = await runtimeWithFrame()
  523. const feature = await runtime.mount({
  524. inject: ['slots'],
  525. apply: (ctx: typeof runtime.ctx) => { ctx.slots.register({ name: 'trt.panel' }, () => <b>p</b>) },
  526. })
  527. const view = runtime.renderRoot()
  528. expect(view.container.textContent).toContain('p')
  529. localStorage.setItem('trt.leftover', 'x')
  530. await runtime.dispose()
  531. expect(view.container.innerHTML).toBe('')
  532. expect(feature.fiber.uid).toBeNull()
  533. expect(localStorage.getItem('trt.leftover')).toBeNull()
  534. await runtime.dispose() // idempotent
  535. await expect(runtime.dispose()).resolves.toBeUndefined()
  536. })
  537. })
  538. describe('single-slot mounting (declare + renderSlot)', () => {
  539. it('renders an absent session-maybe slot through its empty projection', async () => {
  540. const runtime = await SlotTestRuntime.create()
  541. await runtime.declare({ 'trt.maybe': { kind: 'single', scope: 'session-maybe' } })
  542. runtime.slots.register(
  543. { name: 'trt.maybe' },
  544. ({ sessionId }: { sessionId: SessionId | undefined }) => <b>{sessionId ?? 'no session'}</b>,
  545. )
  546. const slot = runtime.renderSlot('trt.maybe', {})
  547. expect(slot.container.textContent).toBe('no session')
  548. await runtime.dispose()
  549. })
  550. it('borrows separate explicit references for sibling views and updates only the supplied view', async () => {
  551. const runtime = await SlotTestRuntime.create()
  552. const firstId = await runtime.sessions.add({ id: 'view-first' })
  553. const secondId = await runtime.sessions.add({ id: 'view-second' })
  554. using first = runtime.sessions.retain(firstId)
  555. await first.ready
  556. using second = runtime.sessions.retain(secondId)
  557. await second.ready
  558. await runtime.declare({
  559. 'trt.chat': { kind: 'single', scope: 'session' },
  560. 'trt.other-chat': { kind: 'single', scope: 'session' },
  561. })
  562. runtime.slots.register({ name: 'trt.chat' }, ({ sessionId }: SessionStandardProps) => <b>{sessionId}</b>)
  563. runtime.slots.register({ name: 'trt.other-chat' }, ({ sessionId }: SessionStandardProps) => <b>{sessionId}</b>)
  564. const retain = vi.spyOn(runtime.sessions, 'retain')
  565. const left = runtime.renderSlot('trt.chat', {}, { session: first })
  566. const right = runtime.renderSlot('trt.other-chat', {}, { session: second })
  567. expect(left.container.textContent).toBe('view-first')
  568. expect(right.container.textContent).toBe('view-second')
  569. left.update({}, { session: second })
  570. expect(left.container.textContent).toBe('view-second')
  571. expect(right.container.textContent).toBe('view-second')
  572. expect(retain).not.toHaveBeenCalled()
  573. expect(runtime.sessions.binding(firstId)).toBe(first.binding)
  574. await runtime.dispose()
  575. })
  576. it('renders one slot inside its data-slot wrapper and updates owner props in place', async () => {
  577. const runtime = await SlotTestRuntime.create()
  578. await runtime.declare({ 'trt.panel': { kind: 'single', scope: 'root' } })
  579. runtime.slots.register(
  580. { name: 'trt.panel' },
  581. ({ label }: { label?: string }) => <b data-testid="panel">{label ?? 'none'}</b>)
  582. const slot = runtime.renderSlot('trt.panel', { label: 'first' })
  583. expect(slot.container.getAttribute('data-slot')).toBe('trt.panel')
  584. expect(slot.view.getByTestId('panel').textContent).toBe('first')
  585. const panel = slot.view.getByTestId('panel')
  586. slot.update({ label: 'second' })
  587. expect(slot.view.getByTestId('panel').textContent).toBe('second')
  588. // In-place re-render: the element identity survived the owner flip.
  589. expect(slot.view.getByTestId('panel')).toBe(panel)
  590. await runtime.dispose()
  591. })
  592. it('views sibling slots of one tree separately and rejects undeclared keys', async () => {
  593. const runtime = await SlotTestRuntime.create()
  594. await runtime.declare({
  595. 'trt.panel': { kind: 'single', scope: 'root' },
  596. 'trt.rows': { kind: 'list', scope: 'root' },
  597. })
  598. runtime.slots.register({ name: 'trt.panel' }, () => <b>panel</b>)
  599. runtime.slots.register({ name: 'trt.rows', id: 'r1' }, () => <i>row</i>)
  600. const panel = runtime.renderSlot('trt.panel', {})
  601. const rows = runtime.renderSlot('trt.rows', {})
  602. expect(panel.container.textContent).toBe('panel')
  603. expect(rows.container.textContent).toBe('row')
  604. expect(() => runtime.renderSlot('trt.chat', {})).toThrow(/without declare\(\)/)
  605. await runtime.dispose()
  606. })
  607. it('folds class hashes and collapses svg internals in snapshots, leaving the live DOM alone', async () => {
  608. const runtime = await SlotTestRuntime.create()
  609. await runtime.declare({ 'trt.panel': { kind: 'single', scope: 'root' } })
  610. runtime.slots.register({ name: 'trt.panel' }, () => (
  611. <div className="_frame_a1b2c3 plain">
  612. <span className="_label_ff00aa">styled</span>
  613. <svg viewBox="0 0 16 16" aria-hidden="true">
  614. <path d="M0 0L16 16" fill="currentColor" />
  615. </svg>
  616. </div>
  617. ))
  618. const slot = runtime.renderSlot('trt.panel', {})
  619. expect(slot.container).toMatchSnapshot()
  620. // The serializer works on a clone: the live DOM keeps hashes and paths.
  621. expect(slot.container.querySelector('div')!.className).toBe('_frame_a1b2c3 plain')
  622. expect(slot.container.querySelector('svg path')).not.toBeNull()
  623. await runtime.dispose()
  624. })
  625. })
  626. describe('fixture session face', () => {
  627. it('fail-loud stubs name the missing verb; supplied overrides run instead', async () => {
  628. const runtime = await SlotTestRuntime.create()
  629. await runtime.sessions.add({ id: 's1' })
  630. const bare = runtime.sessions.behavior('s1')
  631. expect(() => bare.prompt()).toThrow(/prompt is not stubbed/)
  632. expect(() => bare.readAttachment('att-1' as Parameters<typeof bare.readAttachment>[0])).toThrow(/readAttachment is not stubbed/)
  633. expect(() => bare.updateQueue()).toThrow(/updateQueue is not stubbed/)
  634. expect(() => bare.cancel()).toThrow(/cancel is not stubbed/)
  635. expect(() => bare.command()).toThrow(/command is not stubbed/)
  636. expect(() => bare.loadOlder()).toThrow(/loadOlder is not stubbed/)
  637. expect(() => bare.loadThrough()).toThrow(/loadThrough is not stubbed/)
  638. expect(() => bare.rename()).toThrow(/rename is not stubbed/)
  639. const submission = bare.beginSubmission()
  640. expect(submission.requestId).toBe('test-submission-1')
  641. expect(() => { submission.abandon() }).not.toThrow()
  642. await runtime.dispose()
  643. })
  644. it('projects controller values through the real ui-session and renderer path', async () => {
  645. const reference = createSnapshotStore<SessionReference | undefined>(undefined)
  646. const runtime = await runtimeWithFrame(reference)
  647. runtime.slots.register({ name: 'trt.chat' }, (props: SessionStandardProps) => (
  648. <span>todos:{props.useProjection('todos', value => value?.length ?? 0)}</span>
  649. ))
  650. const view = runtime.renderRoot()
  651. await runtime.sessions.add({ id: 's1' })
  652. using bound = runtime.sessions.retain('s1' as SessionId)
  653. await bound.ready
  654. act(() => { reference.set(bound) })
  655. expect(view.container.textContent).toContain('todos:0')
  656. const session = runtime.sessions.behavior('s1')
  657. const face = session.projections.faceOf('todos')
  658. expect(session.projections.faceOf('todos')).toBe(face)
  659. expect(face.getSnapshot()).toBeUndefined()
  660. const seen: unknown[] = []
  661. const off = face.subscribe(() => { seen.push(face.getSnapshot()) })
  662. session.projections.set('todos', [1, 2])
  663. await runtime.flush()
  664. expect(seen).toEqual([[1, 2]])
  665. expect(view.container.textContent).toContain('todos:2')
  666. off()
  667. session.projections.set('todos', [3])
  668. await runtime.flush()
  669. expect(seen).toEqual([[1, 2]]) // unsubscribed
  670. expect(view.container.textContent).toContain('todos:1')
  671. // A never-subscribed key sets without listeners (the empty-notify arm).
  672. session.projections.set('untouched', 1)
  673. await runtime.dispose()
  674. })
  675. it('copies projections into a later Session generation', async () => {
  676. const runtime = await runtimeWithFrame()
  677. const id = await runtime.sessions.add({ id: 'seeded-projection' })
  678. await runtime.sessions.setProjection(id, 'todos', [1, 2])
  679. using reference = runtime.sessions.retain(id)
  680. await reference.ready
  681. expect(reference.binding.session.projections.faceOf('todos').getSnapshot()).toEqual([1, 2])
  682. await runtime.dispose()
  683. })
  684. })
  685. describe('workspaces action face', () => {
  686. it('records every IWorkspaces verb with inert defaults and honors stubs', async () => {
  687. const runtime = await SlotTestRuntime.create()
  688. const ws = runtime.workspaces
  689. const created = await ws.create({ path: '/tmp/alpha' })
  690. expect(created.title).toBe('/tmp/alpha')
  691. const registered = await ws.create({ path: '/tmp/beta' })
  692. expect(registered.path).toBe('/tmp/beta')
  693. const renamed = await ws.rename('w1' as WorkspaceId, 'Renamed')
  694. expect(renamed.title).toBe('Renamed')
  695. await ws.delete('w1' as WorkspaceId)
  696. await ws.insertBefore('w1' as WorkspaceId, 'w2' as WorkspaceId)
  697. const moved = await ws.insertSessionBefore('w1' as WorkspaceId, 's1' as SessionId, 's2' as SessionId)
  698. expect(moved.sessionIds).toEqual(['s1'])
  699. // Default archive mirrors the production effect: the id joins the list
  700. // state's archive set (features render against the same snapshot).
  701. await ws.archiveSession('s1' as SessionId)
  702. expect(ws.list.getSnapshot().archivedSessionIds).toEqual(['s1'])
  703. await ws.archiveSession('s0' as SessionId)
  704. // Default unarchive mirrors it: the id leaves the same set.
  705. await ws.unarchiveSession('s0' as SessionId)
  706. expect(ws.list.getSnapshot().archivedSessionIds).toEqual(['s1'])
  707. expect(ws.calls.map(c => c.method)).toEqual(
  708. ['create', 'create', 'rename', 'delete', 'insertBefore', 'insertSessionBefore',
  709. 'archiveSession', 'archiveSession', 'unarchiveSession'])
  710. ws.stub('create', () => Promise.resolve({ workspaceId: 'ws-x', title: 'X', path: '/x', sessionIds: [] } as never))
  711. ws.stub('rename', () => Promise.resolve({ workspaceId: 'w1', title: 'S', path: '/s', sessionIds: [] } as never))
  712. ws.stub('delete', () => Promise.resolve())
  713. const insertBefore = vi.fn(() => Promise.resolve())
  714. ws.stub('insertBefore', insertBefore)
  715. ws.stub('insertSessionBefore', () => Promise.resolve({ workspaceId: 'w1', title: '', path: '', sessionIds: [] } as never))
  716. ws.stub('archiveSession', () => Promise.resolve())
  717. ws.stub('unarchiveSession', () => Promise.resolve())
  718. expect((await ws.create({ path: '/y' })).title).toBe('X')
  719. expect((await ws.rename('w1' as WorkspaceId, 'z')).title).toBe('S')
  720. await ws.delete('w1' as WorkspaceId)
  721. await ws.insertBefore('w2' as WorkspaceId)
  722. expect(insertBefore).toHaveBeenCalledWith('w2', undefined)
  723. expect((await ws.insertSessionBefore('w1' as WorkspaceId, 's1' as SessionId)).sessionIds).toEqual([])
  724. // The stub replaces the default set mutation: the set stays as-is.
  725. await ws.archiveSession('s2' as SessionId)
  726. expect(ws.list.getSnapshot().archivedSessionIds).toEqual(['s1'])
  727. await ws.unarchiveSession('s1' as SessionId)
  728. expect(ws.list.getSnapshot().archivedSessionIds).toEqual(['s1'])
  729. await runtime.dispose()
  730. })
  731. })
  732. describe('single-slot mounting edge arms', () => {
  733. it('renderSlot fails loud after dispose and after an external unmount', async () => {
  734. const runtime = await SlotTestRuntime.create()
  735. await runtime.declare({ 'trt.panel': { kind: 'single', scope: 'root' } })
  736. runtime.slots.register({ name: 'trt.panel' }, () => <b>p</b>)
  737. runtime.renderSlot('trt.panel', {})
  738. // RTL cleanup empties the mounted tree behind the runtime's back: the
  739. // wrapper lookup names the state instead of returning a dead container.
  740. cleanup()
  741. expect(() => runtime.renderSlot('trt.panel', {})).toThrow(/rendered no wrapper/)
  742. await runtime.dispose()
  743. expect(() => runtime.renderSlot('trt.panel', {})).toThrow(/slot renderer not installed/)
  744. })
  745. it('serializes childless svg untouched next to scoped classes', async () => {
  746. const runtime = await SlotTestRuntime.create()
  747. await runtime.declare({ 'trt.panel': { kind: 'single', scope: 'root' } })
  748. runtime.slots.register({ name: 'trt.panel' }, () => (
  749. <div className="_frame_a1b2c3">
  750. <svg viewBox="0 0 1 1" aria-hidden="true" />
  751. </div>
  752. ))
  753. const slot = runtime.renderSlot('trt.panel', {})
  754. expect(slot.container).toMatchSnapshot()
  755. await runtime.dispose()
  756. })
  757. })
  758. describe('stubbed settings scope', () => {
  759. it('records both write kinds and publishes a Host acceptance to its listeners', async () => {
  760. const host = stubSettingsScope<{ preference: string }>()
  761. let notified = 0
  762. const stop = host.scope.subscribe(() => { notified += 1 })
  763. expect(host.listenerCount()).toBe(1)
  764. expect(host.scope.getSnapshot()).toMatchObject({
  765. status: 'loading', base: undefined, user: undefined,
  766. })
  767. await host.scope.set('preference', 'dark')
  768. await host.scope.unset('preference')
  769. host.publish({
  770. status: 'ready',
  771. value: { preference: 'system' },
  772. base: { preference: 'system' },
  773. revision: 2,
  774. writable: true,
  775. })
  776. expect(host.set).toHaveBeenCalledWith('preference', 'dark')
  777. expect(host.unset).toHaveBeenCalledWith('preference')
  778. expect(notified).toBe(1)
  779. expect(host.scope.getSnapshot()).toMatchObject({ status: 'ready', revision: 2, writable: true })
  780. stop()
  781. expect(host.listenerCount()).toBe(0)
  782. })
  783. })