runtime.spec.tsx 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637
  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 SlotsService + 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 { cleanup } from '@testing-library/react'
  11. import { defineStore } from '@deepseek-ai/dsh-client-runtime/client'
  12. import type { SessionId, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
  13. import type { PropsRenderSlots, SessionStandardProps } from '@deepseek-ai/dsh-client-ui-slots'
  14. import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
  15. declare module '@deepseek-ai/dsh-client-ui-slots' {
  16. interface SlotMap {
  17. 'trt.panel': { kind: 'single'; scope: 'root'; owner: { label?: string } }
  18. 'trt.chat': { kind: 'single'; scope: 'session' }
  19. 'trt.rows': { kind: 'list'; scope: 'root' }
  20. 'trt.rows.hole': { kind: 'single'; scope: 'root' }
  21. }
  22. }
  23. afterEach(cleanup)
  24. type FrameProps = PropsRenderSlots<'trt.panel' | 'trt.chat' | 'trt.rows'>
  25. /** Root frame declaring all three suite slots (render sites for each kind). */
  26. function Frame({ renderSlot, SessionProvider }: FrameProps) {
  27. return (
  28. <>
  29. {renderSlot('trt.panel', { label: 'from-owner' }, { fallback: <i>no panel</i> })}
  30. <SessionProvider empty={() => <i>no session</i>}>
  31. {() => renderSlot('trt.chat', {})}
  32. </SessionProvider>
  33. {renderSlot('trt.rows', {})}
  34. </>
  35. )
  36. }
  37. const CHILDREN = {
  38. 'trt.panel': { kind: 'single', scope: 'root' },
  39. 'trt.chat': { kind: 'single', scope: 'session' },
  40. 'trt.rows': { kind: 'list', scope: 'root' },
  41. } as const
  42. async function runtimeWithFrame() {
  43. const runtime = await SlotTestRuntime.create()
  44. await runtime.root.declare(CHILDREN, Frame)
  45. return runtime
  46. }
  47. describe('root declaration and rendering', () => {
  48. it('renders declared slots through the real renderer: fallback, then a live registration, then unload', async () => {
  49. const runtime = await runtimeWithFrame()
  50. const view = runtime.renderRoot()
  51. expect(view.container.textContent).toContain('no panel')
  52. let dispose = (): void => {}
  53. await runtime.flush() // no-op guard: flush outside mutations is safe
  54. await (async () => {
  55. dispose = runtime.slots.register(
  56. { name: 'trt.panel' },
  57. ({ label }: { label?: string }) => <b>panel:{label}</b>)
  58. await runtime.flush()
  59. })()
  60. expect(view.container.textContent).toContain('panel:from-owner')
  61. dispose()
  62. await runtime.flush()
  63. expect(view.container.textContent).toContain('no panel')
  64. await runtime.dispose()
  65. })
  66. it('fails loud when rendering with no root declaration (production boot-order check)', async () => {
  67. const runtime = await SlotTestRuntime.create()
  68. expect(() => runtime.renderRoot()).toThrow(/'root' has no registration/)
  69. await runtime.dispose()
  70. })
  71. })
  72. describe('sessions', () => {
  73. it('drives SessionProvider: empty state, current session, switch, live snapshot updates', async () => {
  74. const runtime = await runtimeWithFrame()
  75. runtime.slots.register({ name: 'trt.chat' }, (props: SessionStandardProps) => {
  76. const running = props.useSession(s => s.running)
  77. return <span>chat:{props.sessionId}:{String(running)}</span>
  78. })
  79. const view = runtime.renderRoot()
  80. expect(view.container.textContent).toContain('no session')
  81. await runtime.sessions.add({ id: 's1' })
  82. expect(view.container.textContent).toContain('chat:s1:false')
  83. await runtime.sessions.updateSnapshot('s1', (draft) => { draft.running = true })
  84. expect(view.container.textContent).toContain('chat:s1:true')
  85. await runtime.sessions.add({ id: 's2' }) // becomes current by default
  86. expect(view.container.textContent).toContain('chat:s2:false')
  87. await runtime.sessions.setCurrent(undefined)
  88. expect(view.container.textContent).toContain('no session')
  89. await runtime.sessions.setCurrent('s1')
  90. expect(view.container.textContent).toContain('chat:s1:true')
  91. await runtime.dispose()
  92. })
  93. it('add with current:false keeps the selection; unknown ids fail loud on the mutators', async () => {
  94. const runtime = await runtimeWithFrame()
  95. await runtime.sessions.add({ id: 's1' })
  96. await runtime.sessions.add({ id: 's2' }, { current: false })
  97. expect(runtime.sessions.list.getSnapshot().current).toBe('s1')
  98. expect(runtime.sessions.list.getSnapshot().ids).toEqual(['s1', 's2'])
  99. await expect(runtime.sessions.add({ id: 's1' })).rejects.toThrow(/already added/)
  100. await expect(runtime.sessions.setCurrent('ghost')).rejects.toThrow(/not added/)
  101. await expect(runtime.sessions.updateSnapshot('ghost', () => {})).rejects.toThrow(/not added/)
  102. await expect(runtime.sessions.remove('ghost')).rejects.toThrow(/not added/)
  103. expect(() => runtime.sessions.behavior('ghost')).toThrow(/not added/)
  104. await runtime.dispose()
  105. })
  106. it('mints REAL-tag scopes lazily and resolves them through the production scopeOf; bindings expose the behavior face', async () => {
  107. const runtime = await runtimeWithFrame()
  108. const prompt = vi.fn()
  109. await runtime.sessions.add({ id: 's1', session: { prompt } })
  110. expect(runtime.sessions.provideInfo('ghost')).toBeUndefined()
  111. expect(runtime.sessions.scope('ghost')).toBeUndefined()
  112. expect(runtime.sessions.binding('ghost')).toBeUndefined()
  113. const scope = runtime.sessions.scope('s1')!
  114. expect(runtime.sessions.scope('s1')).toBe(scope) // stable per session
  115. expect(runtime.sessions.scopeOf(scope)).toBe('s1')
  116. expect(runtime.sessions.scopeOf(runtime.ctx)).toBeUndefined()
  117. // sessionOf resolves the behavior face off the scope tag.
  118. expect(runtime.sessions.sessionOf(scope)).toBe(runtime.sessions.behavior('s1'))
  119. expect(runtime.sessions.sessionOf(runtime.ctx)).toBeUndefined()
  120. const binding = runtime.sessions.binding('s1')!
  121. expect(binding.sessionId).toBe('s1')
  122. expect(binding.ctx).toBe(scope)
  123. ;(binding.session as { prompt: () => void }).prompt()
  124. expect(prompt).toHaveBeenCalledOnce()
  125. expect(runtime.sessions.behavior('s1')).toBe(binding.session)
  126. // The binding's session doubles as the conversation observable face.
  127. expect((binding.session as { getSnapshot(): { sessionId: string } }).getSnapshot().sessionId).toBe('s1')
  128. // A scoped service resolves through the scope ctx (scope-addressed pattern).
  129. runtime.provide('probe', { hello: 'world' })
  130. expect(scope.get('probe')).toEqual({ hello: 'world' })
  131. await runtime.dispose()
  132. })
  133. it('materializes provide bundles: built-in session hook, custom providers, no-session projection', async () => {
  134. const runtime = await runtimeWithFrame()
  135. await runtime.sessions.add({ id: 's1' })
  136. const info = runtime.sessions.provideInfo('s1')!
  137. expect(info.sessionId).toBe('s1')
  138. expect(info.hooks['session']).toBeDefined() // the built-in useSession source
  139. expect(runtime.sessions.provideInfo('s1')).toBe(info) // identity-stable
  140. // A feature provider (the ui-conversation input pattern): declared names
  141. // materialize per session and land in the no-session roster as undefined.
  142. const off = runtime.sessions.provide({
  143. hooks: ['probe'],
  144. props: ['probeActions'],
  145. resolve: binding => ({
  146. hooks: { probe: { getSnapshot: () => binding.sessionId, subscribe: () => () => {} } },
  147. props: { probeActions: { poke: () => {} } },
  148. }),
  149. })
  150. const enriched = runtime.sessions.provideInfo('s1')!
  151. expect(enriched.hooks['probe']?.getSnapshot()).toBe('s1')
  152. expect(enriched.props['probeActions']).toBeDefined()
  153. const maybe = runtime.sessions.maybeProvideInfo(undefined)
  154. expect(maybe.sessionId).toBeUndefined()
  155. expect(Object.keys(maybe.hooks)).toEqual(['session', 'probe'])
  156. expect(runtime.sessions.maybeProvideInfo('s1')).toBe(runtime.sessions.provideInfo('s1'))
  157. expect(runtime.sessions.maybeProvideInfo('ghost').sessionId).toBeUndefined()
  158. // Misdeclared providers fail loud AT REGISTRATION (the production
  159. // channel rebuilds live bundles eagerly and rolls the roster back):
  160. // missing hook, missing prop, duplicate hook, duplicate prop.
  161. expect(() => runtime.sessions.provide({ hooks: ['void'], resolve: () => ({}) }))
  162. .toThrow(/missing hook "void"/)
  163. expect(() => runtime.sessions.provide({ props: ['void'], resolve: () => ({}) }))
  164. .toThrow(/missing prop "void"/)
  165. expect(() => runtime.sessions.provide({
  166. hooks: ['session'],
  167. resolve: () => ({ hooks: { session: { getSnapshot: () => 0, subscribe: () => () => {} } } }),
  168. })).toThrow(/duplicate hook "session"/)
  169. const propA = runtime.sessions.provide({ props: ['twice'], resolve: () => ({ props: { twice: 1 } }) })
  170. expect(() => runtime.sessions.provide({ props: ['twice'], resolve: () => ({ props: { twice: 2 } }) }))
  171. .toThrow(/duplicate prop "twice"/)
  172. propA()
  173. // The rejected registrations rolled back: the roster still materializes.
  174. expect(runtime.sessions.provideInfo('s1')).toBeDefined()
  175. off()
  176. off() // disposer is idempotent
  177. expect(Object.keys(runtime.sessions.maybeProvideInfo(undefined).hooks)).toEqual(['session'])
  178. await runtime.dispose()
  179. })
  180. it('records service-face calls and retains catalog addresses only for addressed selection', async () => {
  181. const runtime = await runtimeWithFrame()
  182. await runtime.sessions.add({ id: 's1' })
  183. await runtime.sessions.add({ id: 's2' })
  184. const address = {
  185. parentSessionId: 's2' as SessionId,
  186. childSessionId: 's1' as SessionId,
  187. mode: 'continuable' as const,
  188. }
  189. runtime.sessions.openSubagent(address)
  190. await runtime.flush()
  191. expect(runtime.sessions.list.getSnapshot()).toMatchObject({ current: 's1', currentAddress: address })
  192. expect(runtime.sessions.subagentAddress('s1' as SessionId)).toEqual(address)
  193. expect(runtime.sessions.subagentAddress('s2' as SessionId)).toBeUndefined()
  194. await runtime.sessions.updateSummary('s1', { displayTitle: 'renamed', running: true })
  195. expect(runtime.sessions.list.getSnapshot().byId['s1' as SessionId])
  196. .toMatchObject({ displayTitle: 'renamed', running: true })
  197. runtime.sessions.setSubagentCatalogOpen('s2' as SessionId, true)
  198. await runtime.sessions.refreshSubagents('s2' as SessionId)
  199. // The confirmed-switch write-back lands on the row it names and ignores
  200. // one the fixture never added, exactly as production's list upsert does.
  201. runtime.sessions.noteAgentPreset('s1' as SessionId, 'minimal')
  202. runtime.sessions.noteAgentPreset('missing' as SessionId, 'minimal')
  203. await runtime.flush()
  204. expect(runtime.sessions.list.getSnapshot().byId['s1' as SessionId])
  205. .toMatchObject({ agentPreset: 'minimal' })
  206. runtime.sessions.open('s1' as SessionId)
  207. await runtime.flush()
  208. expect(runtime.sessions.list.getSnapshot().current).toBe('s1')
  209. expect(runtime.sessions.list.getSnapshot().currentAddress).toBeUndefined()
  210. runtime.sessions.clear()
  211. await runtime.flush()
  212. expect(runtime.sessions.list.getSnapshot().current).toBeUndefined()
  213. await expect(runtime.sessions.fork({
  214. sessionId: 's1' as SessionId, atSeq: 7, increaseTitle: true,
  215. })).resolves.toBe('s1')
  216. expect(runtime.sessions.calls).toEqual([
  217. { method: 'openSubagent', args: [address] },
  218. { method: 'setSubagentCatalogOpen', args: ['s2', true] },
  219. { method: 'refreshSubagents', args: ['s2'] },
  220. { method: 'open', args: ['s1'] },
  221. { method: 'clear', args: [] },
  222. { method: 'fork', args: [{ sessionId: 's1', atSeq: 7, increaseTitle: true }] },
  223. ])
  224. await runtime.dispose()
  225. })
  226. it('answers search with an empty page until a scenario declares hits, recording every call', async () => {
  227. const runtime = await runtimeWithFrame()
  228. await runtime.sessions.add({ id: 's1' })
  229. const signal = new AbortController().signal
  230. expect(runtime.sessions.searchResultLimit).toBeGreaterThan(0)
  231. await expect(runtime.sessions.search('marker', signal))
  232. .resolves.toEqual({ ok: true, value: { items: [], hasMore: false } })
  233. runtime.sessions.stubSearch(query => ({
  234. items: [{ sessionId: 's1' as SessionId, snippet: `hit: ${query}` }],
  235. hasMore: true,
  236. }))
  237. await expect(runtime.sessions.search('marker', signal)).resolves.toEqual({
  238. ok: true,
  239. value: { items: [{ sessionId: 's1', snippet: 'hit: marker' }], hasMore: true },
  240. })
  241. expect(runtime.sessions.calls).toEqual([
  242. { method: 'search', args: ['marker', signal] },
  243. { method: 'search', args: ['marker', signal] },
  244. ])
  245. await runtime.dispose()
  246. })
  247. })
  248. describe('stores', () => {
  249. const createSuiteStore = () => defineStore({
  250. init: () => ({ note: '' }),
  251. persist: 'trt.store',
  252. actions: { setNote: (d, note: string) => { d.note = note } },
  253. })
  254. it('resolves per-session instances via the host face: shared identity, isolation, action-driven re-render', async () => {
  255. const runtime = await runtimeWithFrame()
  256. const handle = createSuiteStore()
  257. runtime.slots.register(
  258. { name: 'trt.chat', store: handle },
  259. (props: SessionStandardProps & { useStore: <S>(sel: (s: { note: string }) => S) => S }) =>
  260. <span>note:{props.useStore(s => s.note)}</span>)
  261. const view = runtime.renderRoot()
  262. await runtime.sessions.add({ id: 's1' })
  263. expect(() => runtime.storeOf('trt.panel')).toThrow(/no registration/)
  264. const store = runtime.storeOf('trt.chat', 's1')
  265. await runtime.flush()
  266. ;(store.actions['setNote'] as (note: string) => void)('hello')
  267. await runtime.flush()
  268. expect(view.container.textContent).toContain('note:hello')
  269. expect(runtime.storeOf('trt.chat', 's1')).toBe(store) // cached per scope key
  270. await runtime.sessions.add({ id: 's2' })
  271. const other = runtime.storeOf('trt.chat', 's2')
  272. expect(other).not.toBe(store)
  273. expect(other.getSnapshot()).toEqual({ note: '' })
  274. await runtime.dispose()
  275. })
  276. it('storeOf guards: before renderRoot, and for storeless entries', async () => {
  277. const runtime = await runtimeWithFrame()
  278. runtime.slots.register({ name: 'trt.panel' }, () => null)
  279. expect(() => runtime.storeOf('trt.panel')).toThrow(/before renderRoot/)
  280. runtime.renderRoot()
  281. expect(() => runtime.storeOf('trt.panel')).toThrow(/declares no store/)
  282. await runtime.dispose()
  283. })
  284. it('remove() prunes the session store scope: persisted state clears, a re-added session starts fresh', async () => {
  285. const runtime = await runtimeWithFrame()
  286. const handle = createSuiteStore()
  287. runtime.slots.register({ name: 'trt.chat', store: handle }, () => null)
  288. runtime.renderRoot()
  289. await runtime.sessions.add({ id: 's1' })
  290. const doomed = runtime.storeOf('trt.chat', 's1')
  291. ;(doomed.actions['setNote'] as (note: string) => void)('buried')
  292. expect(localStorage.getItem('trt.store.s1')).not.toBeNull()
  293. await runtime.sessions.remove('s1')
  294. expect(localStorage.getItem('trt.store.s1')).toBeNull()
  295. expect(runtime.sessions.list.getSnapshot().ids).toEqual([])
  296. expect(runtime.sessions.provideInfo('s1')).toBeUndefined()
  297. await runtime.sessions.add({ id: 's1' })
  298. const reborn = runtime.storeOf('trt.chat', 's1')
  299. expect(reborn).not.toBe(doomed)
  300. expect(reborn.getSnapshot()).toEqual({ note: '' })
  301. await runtime.dispose()
  302. })
  303. it('remove() also disposes a minted scope fiber; removing a non-current session keeps the selection', async () => {
  304. const runtime = await runtimeWithFrame()
  305. await runtime.sessions.add({ id: 's1' })
  306. await runtime.sessions.add({ id: 's2' }, { current: false })
  307. const scope = runtime.sessions.scope('s1')!
  308. await runtime.sessions.remove('s2')
  309. expect(runtime.sessions.list.getSnapshot().current).toBe('s1')
  310. await runtime.sessions.remove('s1')
  311. expect(scope.fiber.uid).toBeNull() // disposed fiber loses its uid
  312. expect(runtime.sessions.list.getSnapshot().current).toBeUndefined()
  313. await runtime.dispose()
  314. })
  315. })
  316. describe('workspaces', () => {
  317. it('feeds useWorkspaces and records/stubs intent actions', async () => {
  318. const runtime = await runtimeWithFrame()
  319. runtime.slots.register(
  320. { name: 'trt.panel' },
  321. (props: { useWorkspaces: <S>(sel: (s: { phase: string }) => S) => S }) =>
  322. <span>ws:{props.useWorkspaces(s => s.phase)}</span>)
  323. const view = runtime.renderRoot()
  324. expect(view.container.textContent).toContain('ws:ready')
  325. await runtime.workspaces.update((draft) => { draft.phase = 'pending' })
  326. expect(view.container.textContent).toContain('ws:pending')
  327. runtime.workspaces.startSession('w1' as WorkspaceId)
  328. await expect(runtime.workspaces.connectWorkspace('w2' as WorkspaceId)).resolves.toBe('session-of-w2')
  329. expect(runtime.workspaces.calls).toEqual([
  330. { method: 'startSession', args: ['w1'] },
  331. { method: 'connectWorkspace', args: ['w2'] },
  332. ])
  333. const stub = vi.fn(() => Promise.resolve('other' as never))
  334. runtime.workspaces.stub('connectWorkspace', stub)
  335. await expect(runtime.workspaces.connectWorkspace('w3' as WorkspaceId)).resolves.toBe('other')
  336. expect(stub).toHaveBeenCalledOnce()
  337. await runtime.dispose()
  338. })
  339. it('records the browse calls: listDirectory serves an empty home, createDirectory joins, stubs override', async () => {
  340. const runtime = await runtimeWithFrame()
  341. // Defaults: an empty home level and parent/name joining.
  342. await expect(runtime.workspaces.listDirectory()).resolves.toMatchObject({ path: '/home/test', entries: [] })
  343. await expect(runtime.workspaces.listDirectory('/home/test')).resolves.toMatchObject({ path: '/home/test' })
  344. await expect(runtime.workspaces.createDirectory('/home/test', 'fresh')).resolves.toBe('/home/test/fresh')
  345. // The recorded signal seat mirrors the production face (undefined here;
  346. // cancellation tests pass and observe a real one).
  347. expect(runtime.workspaces.calls).toEqual([
  348. { method: 'listDirectory', args: [undefined, undefined] },
  349. { method: 'listDirectory', args: ['/home/test', undefined] },
  350. { method: 'createDirectory', args: ['/home/test', 'fresh'] },
  351. ])
  352. // Stubs replace the defaults like every sibling method.
  353. const listing = { path: '/x', home: '/x', crumbs: [], entries: [] }
  354. const listStub = vi.fn(() => Promise.resolve(listing as never))
  355. runtime.workspaces.stub('listDirectory', listStub)
  356. runtime.workspaces.stub('createDirectory', vi.fn(() => Promise.resolve('/x/made' as never)))
  357. const scan = new AbortController()
  358. await expect(runtime.workspaces.listDirectory('/x', scan.signal)).resolves.toBe(listing)
  359. // The stub receives the signal too, like the production face gives the wire.
  360. expect(listStub).toHaveBeenLastCalledWith('/x', scan.signal)
  361. await expect(runtime.workspaces.createDirectory('/x', 'made')).resolves.toBe('/x/made')
  362. await runtime.dispose()
  363. })
  364. })
  365. describe('feature mount and disposal', () => {
  366. it('mounts a plugin on a real fiber; dispose() cascades entries, declared children, and services', async () => {
  367. const runtime = await runtimeWithFrame()
  368. runtime.provide('layout', { openDetails: vi.fn() })
  369. const feature = await runtime.mount({
  370. inject: ['slots', 'layout'],
  371. apply: (ctx: typeof runtime.ctx) => {
  372. ctx.provide('feature-service', { ok: true })
  373. ctx.slots.register({
  374. name: 'trt.rows',
  375. id: 'row-1',
  376. children: { 'trt.rows.hole': { kind: 'single', scope: 'root' } },
  377. } as never, ((props: { renderSlot: (key: string, owner: object) => unknown }) =>
  378. <div data-testid="row">{props.renderSlot('trt.rows.hole', {}) as React.ReactNode}</div>) as never)
  379. },
  380. })
  381. const view = runtime.renderRoot()
  382. expect(view.getByTestId('row')).toBeTruthy()
  383. expect(runtime.ctx.get('feature-service')).toEqual({ ok: true })
  384. expect(runtime.slots.entries('trt.rows')).toHaveLength(1)
  385. await feature.dispose()
  386. await feature.dispose() // idempotent
  387. expect(runtime.slots.entries('trt.rows')).toHaveLength(0)
  388. expect(runtime.slots.spec('trt.rows.hole')).toBeUndefined()
  389. expect(runtime.ctx.get('feature-service')).toBeUndefined()
  390. expect(view.queryByTestId('row')).toBeNull()
  391. await runtime.dispose()
  392. })
  393. it('mount fails loud on missing services instead of suspending forever', async () => {
  394. const runtime = await runtimeWithFrame()
  395. await expect(runtime.mount({ inject: ['slots', 'absent-service'], apply: () => {} }))
  396. .rejects.toThrow(/missing service\(s\) absent-service/)
  397. await runtime.dispose()
  398. })
  399. it('runtime dispose is idempotent, unmounts views, disposes mounted features, and clears persisted state', async () => {
  400. const runtime = await runtimeWithFrame()
  401. const feature = await runtime.mount({
  402. inject: ['slots'],
  403. apply: (ctx: typeof runtime.ctx) => { ctx.slots.register({ name: 'trt.panel' }, () => <b>p</b>) },
  404. })
  405. const view = runtime.renderRoot()
  406. expect(view.container.textContent).toContain('p')
  407. localStorage.setItem('trt.leftover', 'x')
  408. await runtime.dispose()
  409. expect(view.container.innerHTML).toBe('')
  410. expect(feature.fiber.uid).toBeNull()
  411. expect(localStorage.getItem('trt.leftover')).toBeNull()
  412. await runtime.dispose() // idempotent
  413. await expect(runtime.dispose()).resolves.toBeUndefined()
  414. })
  415. })
  416. describe('single-slot mounting (declare + renderSlot)', () => {
  417. it('renders one slot inside its data-slot wrapper and updates owner props in place', async () => {
  418. const runtime = await SlotTestRuntime.create()
  419. await runtime.declare({ 'trt.panel': { kind: 'single', scope: 'root' } })
  420. runtime.slots.register(
  421. { name: 'trt.panel' },
  422. ({ label }: { label?: string }) => <b data-testid="panel">{label ?? 'none'}</b>)
  423. const slot = runtime.renderSlot('trt.panel', { label: 'first' })
  424. expect(slot.container.getAttribute('data-slot')).toBe('trt.panel')
  425. expect(slot.view.getByTestId('panel').textContent).toBe('first')
  426. const panel = slot.view.getByTestId('panel')
  427. slot.update({ label: 'second' })
  428. expect(slot.view.getByTestId('panel').textContent).toBe('second')
  429. // In-place re-render: the element identity survived the owner flip.
  430. expect(slot.view.getByTestId('panel')).toBe(panel)
  431. await runtime.dispose()
  432. })
  433. it('views sibling slots of one tree separately and rejects undeclared keys', async () => {
  434. const runtime = await SlotTestRuntime.create()
  435. await runtime.declare({
  436. 'trt.panel': { kind: 'single', scope: 'root' },
  437. 'trt.rows': { kind: 'list', scope: 'root' },
  438. })
  439. runtime.slots.register({ name: 'trt.panel' }, () => <b>panel</b>)
  440. runtime.slots.register({ name: 'trt.rows', id: 'r1' }, () => <i>row</i>)
  441. const panel = runtime.renderSlot('trt.panel', {})
  442. const rows = runtime.renderSlot('trt.rows', {})
  443. expect(panel.container.textContent).toBe('panel')
  444. expect(rows.container.textContent).toBe('row')
  445. expect(() => runtime.renderSlot('trt.chat', {})).toThrow(/without declare\(\)/)
  446. await runtime.dispose()
  447. })
  448. it('folds class hashes and collapses svg internals in snapshots, leaving the live DOM alone', async () => {
  449. const runtime = await SlotTestRuntime.create()
  450. await runtime.declare({ 'trt.panel': { kind: 'single', scope: 'root' } })
  451. runtime.slots.register({ name: 'trt.panel' }, () => (
  452. <div className="_frame_a1b2c3 plain">
  453. <span className="_label_ff00aa">styled</span>
  454. <svg viewBox="0 0 16 16" aria-hidden="true">
  455. <path d="M0 0L16 16" fill="currentColor" />
  456. </svg>
  457. </div>
  458. ))
  459. const slot = runtime.renderSlot('trt.panel', {})
  460. expect(slot.container).toMatchSnapshot()
  461. // The serializer works on a clone: the live DOM keeps hashes and paths.
  462. expect(slot.container.querySelector('div')!.className).toBe('_frame_a1b2c3 plain')
  463. expect(slot.container.querySelector('svg path')).not.toBeNull()
  464. await runtime.dispose()
  465. })
  466. })
  467. describe('fixture session face', () => {
  468. it('fail-loud stubs name the missing verb; supplied overrides run instead', async () => {
  469. const runtime = await SlotTestRuntime.create()
  470. await runtime.sessions.add({ id: 's1' })
  471. const bare = runtime.sessions.behavior('s1')
  472. expect(() => bare.prompt()).toThrow(/prompt is not stubbed/)
  473. expect(() => bare.readAttachment('att-1' as Parameters<typeof bare.readAttachment>[0])).toThrow(/readAttachment is not stubbed/)
  474. expect(() => bare.updateQueue()).toThrow(/updateQueue is not stubbed/)
  475. expect(() => bare.cancel()).toThrow(/cancel is not stubbed/)
  476. expect(() => bare.command()).toThrow(/command is not stubbed/)
  477. expect(() => bare.loadOlder()).toThrow(/loadOlder is not stubbed/)
  478. expect(() => bare.rename()).toThrow(/rename is not stubbed/)
  479. await runtime.dispose()
  480. })
  481. it('projections faces are identity-stable per key, read absent, and notify on set', async () => {
  482. const runtime = await SlotTestRuntime.create()
  483. await runtime.sessions.add({ id: 's1' })
  484. const session = runtime.sessions.behavior('s1')
  485. const face = session.projections.faceOf('todos')
  486. expect(session.projections.faceOf('todos')).toBe(face)
  487. expect(face.getSnapshot()).toBeUndefined()
  488. const seen: unknown[] = []
  489. const off = face.subscribe(() => { seen.push(face.getSnapshot()) })
  490. session.projections.set('todos', [1, 2])
  491. expect(seen).toEqual([[1, 2]])
  492. off()
  493. session.projections.set('todos', [3])
  494. expect(seen).toEqual([[1, 2]]) // unsubscribed
  495. // A never-subscribed key sets without listeners (the empty-notify arm).
  496. session.projections.set('untouched', 1)
  497. // The provide bundle hands the same store to the render side.
  498. const info = runtime.sessions.provideInfo('s1')!
  499. expect(info.projections?.faceOf('todos').getSnapshot()).toEqual([3])
  500. // A roster change rebuilds the ALREADY-materialized bundle eagerly
  501. // (production channel semantics: mounted entries must see the provider)
  502. // and skips never-materialized records (they pick the roster up lazily).
  503. await runtime.sessions.add({ id: 's-lazy' }, { current: false })
  504. const offProbe = runtime.sessions.provide({
  505. hooks: ['probe2'],
  506. resolve: () => ({ hooks: { probe2: { getSnapshot: () => 1, subscribe: () => () => {} } } }),
  507. })
  508. const rebuilt = runtime.sessions.provideInfo('s1')!
  509. expect(rebuilt).not.toBe(info)
  510. expect(rebuilt.hooks['probe2']).toBeDefined()
  511. offProbe()
  512. await runtime.dispose()
  513. })
  514. })
  515. describe('workspaces action face', () => {
  516. it('records every IWorkspaces verb with inert defaults and honors stubs', async () => {
  517. const runtime = await SlotTestRuntime.create()
  518. const ws = runtime.workspaces
  519. const created = await ws.create({ path: '/tmp/alpha' })
  520. expect(created.title).toBe('/tmp/alpha')
  521. const registered = await ws.create({ path: '/tmp/beta' })
  522. expect(registered.path).toBe('/tmp/beta')
  523. await expect(ws.pickDirectory()).resolves.toBeNull()
  524. const renamed = await ws.rename('w1' as WorkspaceId, 'Renamed')
  525. expect(renamed.title).toBe('Renamed')
  526. await ws.delete('w1' as WorkspaceId)
  527. await ws.openPath('/proj/file.ts')
  528. const moved = await ws.insertSessionBefore('w1' as WorkspaceId, 's1' as SessionId, 's2' as SessionId)
  529. expect(moved.sessionIds).toEqual(['s1'])
  530. // Default archive mirrors the production effect: the id joins the list
  531. // state's archive set (features render against the same snapshot).
  532. await ws.archiveSession('s1' as SessionId)
  533. expect(ws.list.getSnapshot().archivedSessionIds).toEqual(['s1'])
  534. expect(ws.calls.map(c => c.method)).toEqual(
  535. ['create', 'create', 'pickDirectory', 'rename', 'delete', 'openPath', 'insertSessionBefore', 'archiveSession'])
  536. ws.stub('create', () => Promise.resolve({ workspaceId: 'ws-x', title: 'X', path: '/x', sessionIds: [] } as never))
  537. ws.stub('pickDirectory', () => Promise.resolve('/picked'))
  538. ws.stub('rename', () => Promise.resolve({ workspaceId: 'w1', title: 'S', path: '/s', sessionIds: [] } as never))
  539. ws.stub('delete', () => Promise.resolve())
  540. ws.stub('openPath', () => Promise.resolve())
  541. ws.stub('insertSessionBefore', () => Promise.resolve({ workspaceId: 'w1', title: '', path: '', sessionIds: [] } as never))
  542. ws.stub('archiveSession', () => Promise.resolve())
  543. expect((await ws.create({ path: '/y' })).title).toBe('X')
  544. await expect(ws.pickDirectory()).resolves.toBe('/picked')
  545. expect((await ws.rename('w1' as WorkspaceId, 'z')).title).toBe('S')
  546. await ws.delete('w1' as WorkspaceId)
  547. await ws.openPath('/other')
  548. expect((await ws.insertSessionBefore('w1' as WorkspaceId, 's1' as SessionId)).sessionIds).toEqual([])
  549. // The stub replaces the default set mutation: the set stays as-is.
  550. await ws.archiveSession('s2' as SessionId)
  551. expect(ws.list.getSnapshot().archivedSessionIds).toEqual(['s1'])
  552. await runtime.dispose()
  553. })
  554. })
  555. describe('single-slot mounting edge arms', () => {
  556. it('renderSlot fails loud after dispose and after an external unmount', async () => {
  557. const runtime = await SlotTestRuntime.create()
  558. await runtime.declare({ 'trt.panel': { kind: 'single', scope: 'root' } })
  559. runtime.slots.register({ name: 'trt.panel' }, () => <b>p</b>)
  560. runtime.renderSlot('trt.panel', {})
  561. // RTL cleanup empties the mounted tree behind the runtime's back: the
  562. // wrapper lookup names the state instead of returning a dead container.
  563. cleanup()
  564. expect(() => runtime.renderSlot('trt.panel', {})).toThrow(/rendered no wrapper/)
  565. await runtime.dispose()
  566. // After dispose the root registration is gone: the production boot-order
  567. // check fires before any wrapper lookup.
  568. expect(() => runtime.renderSlot('trt.panel', {})).toThrow(/'root' has no registration/)
  569. })
  570. it('serializes childless svg untouched next to scoped classes', async () => {
  571. const runtime = await SlotTestRuntime.create()
  572. await runtime.declare({ 'trt.panel': { kind: 'single', scope: 'root' } })
  573. runtime.slots.register({ name: 'trt.panel' }, () => (
  574. <div className="_frame_a1b2c3">
  575. <svg viewBox="0 0 1 1" aria-hidden="true" />
  576. </div>
  577. ))
  578. const slot = runtime.renderSlot('trt.panel', {})
  579. expect(slot.container).toMatchSnapshot()
  580. await runtime.dispose()
  581. })
  582. })