runtime.client.spec.tsx 25 KB

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