slots-service.spec.ts 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666
  1. /**
  2. * SlotsService terminal-design account (design.md §11-3 main landing):
  3. * built-in 'root', the three load-time throws (duplicate declaration /
  4. * undeclared contribution / cross-scope store handle), the renderer install
  5. * seam (double install / not installed / non-root key), store instance
  6. * resolution and lifecycle on the ledger axis, and the entry-unload cascade.
  7. */
  8. import { Context } from 'cordis'
  9. import { describe, expect, it, vi } from 'vitest'
  10. import type { FC } from 'react'
  11. import type { SlotRendererHost } from '@deepseek-ai/dsh-client-ui-slots'
  12. import { SlotsService } from '../src/client/slots.ts'
  13. // Test-only slot keys (merged so the typed entries/spec faces accept them).
  14. declare module '@deepseek-ai/dsh-client-ui-slots' {
  15. interface SlotMap {
  16. 't.host': { kind: 'single'; scope: 'root' }
  17. 't.panel': { kind: 'single'; scope: 'session' }
  18. 't.rows': { kind: 'list'; scope: 'root' }
  19. }
  20. }
  21. const C: FC<object> = () => null
  22. /**
  23. * Register/install/renderSlot through a type-erased view: the typed register
  24. * face rides wave-1 ui-slots types (red until that wave lands); the runtime
  25. * semantics under test are final.
  26. */
  27. interface ErasedService {
  28. register(options: object, component: unknown): () => void
  29. inject(name: string, callback: () => (() => void) | Iterable<() => void>): () => void
  30. install(renderer: object): void
  31. renderSlot(key: string, owner: object): unknown
  32. }
  33. interface Bench {
  34. ctx: Context
  35. svc: SlotsService
  36. erased: ErasedService
  37. }
  38. async function boot(): Promise<Bench> {
  39. const ctx = new Context()
  40. const fiber = ctx.plugin(SlotsService)
  41. await fiber
  42. // Service accessor (ctx.get reads the reflect store, which Service-class
  43. // plugins do not write; the accessor is the product path).
  44. const svc = ctx.slots
  45. return { ctx, svc, erased: svc as unknown as ErasedService }
  46. }
  47. /** Engine-shaped instance stub (bare-source form: subscribe/getSnapshot + baked actions + clearPersisted). */
  48. interface FakeInstance {
  49. getSnapshot: () => undefined
  50. subscribe: () => () => void
  51. actions: Record<string, never>
  52. clearPersisted: ReturnType<typeof vi.fn>
  53. }
  54. /** Fake store handle factory (create-count and clearPersisted observable). */
  55. function fakeHandle() {
  56. const created: FakeInstance[] = []
  57. const handle = {
  58. create: vi.fn((_scopeKey?: string): FakeInstance => {
  59. const instance: FakeInstance = {
  60. getSnapshot: () => undefined, subscribe: () => () => undefined,
  61. actions: {}, clearPersisted: vi.fn(),
  62. }
  63. created.push(instance)
  64. return instance
  65. }),
  66. }
  67. return { handle, created }
  68. }
  69. /**
  70. * Install a capturing renderer, occupy 'root' (declaring `children` in the
  71. * same call — 'root' is single, so the one occupant is also the declarer),
  72. * and pull the host face out through renderSlot('root').
  73. */
  74. function captureHost(bench: Bench, children?: object): SlotRendererHost {
  75. let host: SlotRendererHost | undefined
  76. bench.erased.install({
  77. renderRoot: (h: SlotRendererHost) => { host = h; return 'rendered' },
  78. })
  79. bench.erased.register({ name: 'root', ...(children !== undefined ? { children } : {}) }, C)
  80. bench.ctx.reflect.provide('sessions', fakeSessions())
  81. bench.ctx.reflect.provide('workspaces', fakeWorkspaces())
  82. bench.erased.renderSlot('root', {})
  83. if (host === undefined) throw new Error('renderer never received the host')
  84. return host
  85. }
  86. /** Minimal independent Workspace list source for the renderer host seam. */
  87. function fakeWorkspaces() {
  88. const state = { items: [], phase: 'ready' as const }
  89. return { list: { getSnapshot: () => state, subscribe: () => () => undefined } }
  90. }
  91. /** Minimal sessions face for the host seam (list observable + current provide projection). */
  92. function fakeSessions() {
  93. const state = { ids: [], byId: {}, current: undefined as string | undefined }
  94. const absentInfo = { sessionId: undefined, hooks: { session: undefined }, props: {} }
  95. return {
  96. list: { getSnapshot: () => state, subscribe: () => () => undefined },
  97. currentProvideInfo: { getSnapshot: () => absentInfo, subscribe: () => () => undefined },
  98. }
  99. }
  100. describe("built-in 'root'", () => {
  101. it('is declared at construction: spec readable, occupancy open, no plugin needed', async () => {
  102. const bench = await boot()
  103. expect(bench.svc.spec('root')).toEqual({ kind: 'single', scope: 'root' })
  104. expect(() => bench.erased.register({ name: 'root' }, C)).not.toThrow()
  105. expect(bench.svc.entries('root')).toHaveLength(1)
  106. })
  107. it('rejects a second declaration of root, attributing the built-in row', async () => {
  108. const bench = await boot()
  109. expect(() => bench.erased.register({
  110. name: 'root', children: { 'root': { kind: 'single', scope: 'root' } },
  111. }, C)).toThrow(/already declared.*built-in/)
  112. })
  113. })
  114. describe('load-time validation', () => {
  115. it('throws on contributing into an undeclared slot', async () => {
  116. const bench = await boot()
  117. expect(() => bench.erased.register({ name: 't.host' }, C)).toThrow(/slot "t.host" is not declared/)
  118. })
  119. it('throws on a duplicate declaration, naming the slot and the prior declarant', async () => {
  120. const bench = await boot()
  121. bench.erased.register({ name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } } }, C)
  122. bench.erased.register({
  123. name: 't.host', children: { 't.rows': { kind: 'list', scope: 'root' } },
  124. }, C)
  125. expect(() => bench.erased.register({
  126. name: 't.rows', id: 'r1', children: { 't.rows': { kind: 'list', scope: 'root' } },
  127. }, C)).toThrow(/slot "t.rows" is already declared.*"t.host"/)
  128. })
  129. it('throws when one store handle is bound to two scopes', async () => {
  130. const bench = await boot()
  131. bench.erased.register({
  132. name: 'root',
  133. children: {
  134. 't.host': { kind: 'single', scope: 'root' },
  135. 't.panel': { kind: 'single', scope: 'session' },
  136. },
  137. }, C)
  138. const { handle } = fakeHandle()
  139. bench.erased.register({ name: 't.host', store: handle }, C)
  140. expect(() => bench.erased.register({ name: 't.panel', store: handle }, C))
  141. .toThrow(/one handle, one scope/)
  142. })
  143. it('commits nothing when the core rejects the entry (children stay undeclared)', async () => {
  144. const bench = await boot()
  145. bench.erased.register({ name: 'root' }, C) // 'root' single slot now occupied
  146. expect(() => bench.erased.register({
  147. name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
  148. }, C)).toThrow(/already has a registration/)
  149. // The failing call's declaration must not have landed.
  150. expect(() => bench.erased.register({ name: 't.host' }, C)).toThrow(/is not declared/)
  151. })
  152. })
  153. describe('declaration injection', () => {
  154. it('activates immediately and ignores ordinary entry mutations', async () => {
  155. const bench = await boot()
  156. bench.erased.register({
  157. name: 'root', children: { 't.rows': { kind: 'list', scope: 'root' } },
  158. }, C)
  159. const setup = vi.fn(() => bench.erased.register({ name: 't.rows', id: 'injected' }, C))
  160. const dispose = bench.erased.inject('t.rows', setup)
  161. expect(setup).toHaveBeenCalledOnce()
  162. bench.erased.register({ name: 't.rows', id: 'ordinary' }, C)
  163. await Promise.resolve()
  164. expect(setup).toHaveBeenCalledOnce()
  165. dispose()
  166. expect(bench.svc.entries('t.rows').map(entry => entry.options.id)).toEqual(['ordinary'])
  167. })
  168. it('waits for declaration, cleans up on collapse, and reruns after redeclaration', async () => {
  169. const bench = await boot()
  170. const cleanup = vi.fn()
  171. const setup = vi.fn(() => {
  172. const unregister = bench.erased.register({ name: 't.host' }, C)
  173. return () => { unregister(); cleanup() }
  174. })
  175. bench.erased.inject('t.host', setup)
  176. expect(setup).not.toHaveBeenCalled()
  177. const disposeFrame = bench.erased.register({
  178. name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
  179. }, C)
  180. await Promise.resolve()
  181. expect(setup).toHaveBeenCalledOnce()
  182. expect(bench.svc.entries('t.host')).toHaveLength(1)
  183. disposeFrame()
  184. await Promise.resolve()
  185. expect(cleanup).toHaveBeenCalledOnce()
  186. expect(bench.svc.entries('t.host')).toHaveLength(0)
  187. bench.erased.register({
  188. name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
  189. }, C)
  190. await Promise.resolve()
  191. expect(setup).toHaveBeenCalledTimes(2)
  192. expect(bench.svc.entries('t.host')).toHaveLength(1)
  193. })
  194. it('observes a same-tick collapse and redeclaration through the declaration epoch', async () => {
  195. const bench = await boot()
  196. const firstFrame = bench.erased.register({
  197. name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
  198. }, C)
  199. const cleanup = vi.fn()
  200. const setup = vi.fn(() => {
  201. const unregister = bench.erased.register({ name: 't.host' }, C)
  202. return () => { unregister(); cleanup() }
  203. })
  204. bench.erased.inject('t.host', setup)
  205. firstFrame()
  206. bench.erased.register({
  207. name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
  208. }, C)
  209. await Promise.resolve()
  210. expect(cleanup).toHaveBeenCalledOnce()
  211. expect(setup).toHaveBeenCalledTimes(2)
  212. expect(bench.svc.entries('t.host')).toHaveLength(1)
  213. })
  214. it('plugin disposal removes an active injection and prevents a waiting one from resurrecting', async () => {
  215. const active = await boot()
  216. active.erased.register({
  217. name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
  218. }, C)
  219. const activeFiber = active.ctx.plugin({
  220. name: 'active-injection',
  221. inject: ['slots'],
  222. apply: (ctx: Context) => { ctx.slots.inject('t.host', () => ctx.slots.register({ name: 't.host' }, C)) },
  223. })
  224. await activeFiber.await()
  225. expect(active.svc.entries('t.host')).toHaveLength(1)
  226. await activeFiber.dispose()
  227. expect(active.svc.entries('t.host')).toHaveLength(0)
  228. const waiting = await boot()
  229. const setup = vi.fn(() => waiting.erased.register({ name: 't.host' }, C))
  230. const waitingFiber = waiting.ctx.plugin({
  231. name: 'waiting-injection',
  232. inject: ['slots'],
  233. apply: (ctx: Context) => { ctx.slots.inject('t.host', setup) },
  234. })
  235. await waitingFiber.await()
  236. await waitingFiber.dispose()
  237. waiting.erased.register({
  238. name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
  239. }, C)
  240. await Promise.resolve()
  241. expect(setup).not.toHaveBeenCalled()
  242. })
  243. it('rolls back earlier yielded registrations when generator setup fails', async () => {
  244. const bench = await boot()
  245. bench.erased.register({
  246. name: 'root',
  247. children: {
  248. 't.host': { kind: 'single', scope: 'root' },
  249. 't.rows': { kind: 'list', scope: 'root' },
  250. },
  251. }, C)
  252. bench.erased.register({ name: 't.host' }, C)
  253. expect(() => bench.erased.inject('t.rows', function* () {
  254. yield bench.erased.register({ name: 't.rows', id: 'rolled-back' }, C)
  255. yield bench.erased.register({ name: 't.host' }, C)
  256. })).toThrow(/already has a registration/)
  257. expect(bench.svc.entries('t.rows')).toHaveLength(0)
  258. })
  259. it('contains and wraps a delayed setup failure so later slot listeners still run', async () => {
  260. const bench = await boot()
  261. const failures: unknown[] = []
  262. const onLoud = (error: unknown): void => { failures.push(error) }
  263. process.on('uncaughtException', onLoud)
  264. try {
  265. const setup = vi.fn(function* () {
  266. yield bench.erased.register({ name: 't.host' }, C)
  267. throw null
  268. })
  269. bench.erased.inject('t.host', setup)
  270. const later = vi.fn(() => () => undefined)
  271. bench.erased.inject('t.host', later)
  272. const disposeFrame = bench.erased.register({
  273. name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
  274. }, C)
  275. await new Promise(resolve => setTimeout(resolve, 20))
  276. expect(failures).toHaveLength(1)
  277. expect(failures[0]).toBeInstanceOf(Error)
  278. expect(String(failures[0])).toContain('null')
  279. expect(later).toHaveBeenCalledOnce()
  280. disposeFrame()
  281. bench.erased.register({
  282. name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
  283. }, C)
  284. expect(setup).toHaveBeenCalledOnce()
  285. } finally {
  286. process.off('uncaughtException', onLoud)
  287. }
  288. })
  289. it('skips a stopped controller retained by the current declaration snapshot', async () => {
  290. const bench = await boot()
  291. let stopLater = (): void => {}
  292. const first = vi.fn(() => {
  293. stopLater()
  294. return () => undefined
  295. })
  296. const later = vi.fn(() => () => undefined)
  297. bench.erased.inject('t.host', first)
  298. stopLater = bench.erased.inject('t.host', later)
  299. bench.erased.register({
  300. name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
  301. }, C)
  302. expect(first).toHaveBeenCalledOnce()
  303. expect(later).not.toHaveBeenCalled()
  304. })
  305. it('keeps a nested redeclaration activation when the outer collapse resumes', async () => {
  306. const bench = await boot()
  307. const disposeFrame = bench.erased.register({
  308. name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
  309. }, C)
  310. let disposeReplacement = (): void => {}
  311. let replaced = false
  312. const first = vi.fn(() => () => {
  313. if (replaced) return
  314. replaced = true
  315. disposeReplacement = bench.erased.register({
  316. name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
  317. }, C)
  318. })
  319. const later = vi.fn(() => () => undefined)
  320. bench.erased.inject('t.host', first)
  321. bench.erased.inject('t.host', later)
  322. disposeFrame()
  323. expect(first).toHaveBeenCalledTimes(2)
  324. expect(later).toHaveBeenCalledTimes(2)
  325. expect(bench.svc.spec('t.host')).toBeDefined()
  326. disposeReplacement()
  327. })
  328. it('cancels a waiting injection when its contributor is already unloading', async () => {
  329. const bench = await boot()
  330. const setup = vi.fn(() => bench.erased.register({ name: 't.host' }, C))
  331. let release = (): void => {}
  332. const blocked = new Promise<void>((resolve) => { release = resolve })
  333. const pauseUnload = vi.fn(async () => { await blocked })
  334. const contributor = bench.ctx.plugin({
  335. name: 'unloading-injection',
  336. inject: ['slots'],
  337. apply: (ctx: Context) => {
  338. ctx.slots.inject('t.host', setup)
  339. ctx.effect(() => pauseUnload, 'pause contributor unload')
  340. },
  341. })
  342. await contributor.await()
  343. const disposing = contributor.dispose()
  344. expect(() => bench.erased.register({
  345. name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
  346. }, C)).not.toThrow()
  347. expect(setup).not.toHaveBeenCalled()
  348. await vi.waitFor(() => { expect(pauseUnload).toHaveBeenCalledOnce() })
  349. release()
  350. await disposing
  351. })
  352. it('supports dynamic plugin replacement without retaining the old rendered entry', async () => {
  353. const bench = await boot()
  354. bench.erased.register({
  355. name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
  356. }, C)
  357. const componentA = (): null => null
  358. const componentB = (): null => null
  359. const mount = (name: string, component: FC<object>) => bench.ctx.plugin({
  360. name,
  361. inject: ['slots'],
  362. apply: (ctx: Context) => { ctx.slots.inject('t.host', () => ctx.slots.register({ name: 't.host' }, component)) },
  363. })
  364. const first = mount('replacement-a', componentA)
  365. await first.await()
  366. expect(bench.svc.entries('t.host')[0]?.component).toBe(componentA)
  367. await first.dispose()
  368. expect(bench.svc.entries('t.host')).toHaveLength(0)
  369. const second = mount('replacement-b', componentB)
  370. await second.await()
  371. expect(bench.svc.entries('t.host')[0]?.component).toBe(componentB)
  372. })
  373. it('releases service-layer store state when the declaration collapses', async () => {
  374. const bench = await boot()
  375. let host: SlotRendererHost | undefined
  376. bench.erased.install({ renderRoot: (value: SlotRendererHost) => { host = value; return null } })
  377. bench.ctx.reflect.provide('sessions', fakeSessions())
  378. bench.ctx.reflect.provide('workspaces', fakeWorkspaces())
  379. const disposeFrame = bench.erased.register({
  380. name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
  381. }, C)
  382. bench.erased.renderSlot('root', {})
  383. if (host === undefined) throw new Error('renderer never received the host')
  384. const { handle } = fakeHandle()
  385. bench.erased.inject('t.host', () => bench.erased.register({ name: 't.host', store: handle }, C))
  386. const oldEntry = host.entriesOf('t.host')[0]
  387. expect(host.storeOf(oldEntry as never, undefined)).toBeDefined()
  388. disposeFrame()
  389. expect(() => host?.storeOf(oldEntry as never, undefined)).toThrow(/not registered/)
  390. bench.erased.register({
  391. name: 'root', children: { 't.panel': { kind: 'single', scope: 'session' } },
  392. }, C)
  393. bench.erased.register({ name: 't.panel', store: handle }, C)
  394. const panelEntry = host.entriesOf('t.panel')[0]
  395. expect(host.storeOf(panelEntry as never, 's1')).toBeDefined()
  396. expect(handle.create).toHaveBeenLastCalledWith('s1')
  397. })
  398. })
  399. describe('renderer install seam', () => {
  400. it('throws on renderSlot before install (boot-order guidance)', async () => {
  401. const bench = await boot()
  402. expect(() => bench.erased.renderSlot('root', {})).toThrow(/renderer not installed/)
  403. })
  404. it('throws on double install', async () => {
  405. const bench = await boot()
  406. bench.erased.install({ renderRoot: () => null })
  407. expect(() => { bench.erased.install({ renderRoot: () => null }) }).toThrow(/already installed/)
  408. })
  409. it('throws on any non-root key (single ctx-level entry)', async () => {
  410. const bench = await boot()
  411. bench.erased.install({ renderRoot: () => null })
  412. expect(() => bench.erased.renderSlot('t.host', {})).toThrow(/only renders 'root'/)
  413. })
  414. it("throws on renderSlot('root') before any root registration", async () => {
  415. const bench = await boot()
  416. bench.erased.install({ renderRoot: () => null })
  417. expect(() => bench.erased.renderSlot('root', {})).toThrow(/no registration/)
  418. })
  419. it('renders through the installed renderer and returns its product', async () => {
  420. const bench = await boot()
  421. const renderRoot = vi.fn(() => 'tree')
  422. bench.erased.install({ renderRoot })
  423. bench.erased.register({ name: 'root' }, C)
  424. bench.ctx.reflect.provide('sessions', fakeSessions())
  425. bench.ctx.reflect.provide('workspaces', fakeWorkspaces())
  426. expect(bench.erased.renderSlot('root', {})).toBe('tree')
  427. expect(renderRoot).toHaveBeenCalledTimes(1)
  428. })
  429. it('fails before rendering when the Workspace object layer is absent', async () => {
  430. const bench = await boot()
  431. bench.erased.install({ renderRoot: () => null })
  432. bench.erased.register({ name: 'root' }, C)
  433. bench.ctx.reflect.provide('sessions', fakeSessions())
  434. expect(() => bench.erased.renderSlot('root', {})).toThrow(/workspaces service mounted/)
  435. })
  436. })
  437. describe('host face', () => {
  438. it('serves entriesOf/specOf/isLive off the ledger and flips isLive on disposal', async () => {
  439. const bench = await boot()
  440. const host = captureHost(bench, { 't.host': { kind: 'single', scope: 'root' } })
  441. const dispose = bench.erased.register({ name: 't.host' }, C)
  442. const rootEntry = host.entriesOf('root')[0]
  443. expect(rootEntry).toBeDefined()
  444. expect(rootEntry?.component).toBe(C)
  445. expect(host.specOf('root')).toEqual({ kind: 'single', scope: 'root' })
  446. expect(host.specOf('t.host')).toEqual({ kind: 'single', scope: 'root' })
  447. const childEntry = host.entriesOf('t.host')[0]
  448. expect(host.isLive(childEntry as never)).toBe(true)
  449. dispose()
  450. expect(host.isLive(childEntry as never)).toBe(false)
  451. expect(host.entriesOf('t.host')).toHaveLength(0)
  452. })
  453. it('exposes the session list and the atomic current provide projection', async () => {
  454. const bench = await boot()
  455. const host = captureHost(bench)
  456. expect(host.sessions.list.getSnapshot()).toMatchObject({ ids: [] })
  457. expect(host.sessions.provideInfo.getSnapshot()).toMatchObject({ sessionId: undefined })
  458. })
  459. it('exposes the independent Workspace list source', async () => {
  460. const bench = await boot()
  461. const host = captureHost(bench)
  462. expect(host.workspaces.list.getSnapshot()).toEqual({ items: [], phase: 'ready' })
  463. })
  464. })
  465. describe('store instance axis', () => {
  466. /** Boot with 'root' occupied and the three test children declared. */
  467. async function storeBench() {
  468. const bench = await boot()
  469. const host = captureHost(bench, {
  470. 't.host': { kind: 'single', scope: 'root' },
  471. 't.rows': { kind: 'list', scope: 'root' },
  472. 't.panel': { kind: 'single', scope: 'session' },
  473. })
  474. return { bench, host }
  475. }
  476. it('resolves one instance per (handle x root scope) shared across entries', async () => {
  477. const { bench, host } = await storeBench()
  478. const { handle } = fakeHandle()
  479. bench.erased.register({ name: 't.host', store: handle }, C)
  480. bench.erased.register({ name: 't.rows', id: 'a', store: handle }, C)
  481. const [hostEntry] = host.entriesOf('t.host')
  482. const [rowEntry] = host.entriesOf('t.rows')
  483. const a = host.storeOf(hostEntry as never, undefined)
  484. const b = host.storeOf(rowEntry as never, undefined)
  485. expect(a).toBeDefined()
  486. expect(a).toBe(b) // shared handle, same scope key = same instance
  487. expect(handle.create).toHaveBeenCalledTimes(1)
  488. expect(handle.create).toHaveBeenCalledWith() // root scope: keyless create
  489. })
  490. it('resolves per-session instances keyed by session id, created with the scope key', async () => {
  491. const { bench, host } = await storeBench()
  492. const { handle } = fakeHandle()
  493. bench.erased.register({ name: 't.panel', store: handle }, C)
  494. const [entry] = host.entriesOf('t.panel')
  495. const s1 = host.storeOf(entry as never, 's1')
  496. const s2 = host.storeOf(entry as never, 's2')
  497. expect(s1).not.toBe(s2)
  498. expect(host.storeOf(entry as never, 's1')).toBe(s1) // cached per key
  499. expect(handle.create).toHaveBeenCalledWith('s1')
  500. expect(handle.create).toHaveBeenCalledWith('s2')
  501. expect(() => host.storeOf(entry as never, undefined)).toThrow(/requires a session id/)
  502. })
  503. it('mints a fresh handle per register for the factory (exclusive) form', async () => {
  504. const { bench, host } = await storeBench()
  505. const factory = vi.fn(() => fakeHandle().handle)
  506. bench.erased.register({ name: 't.host', store: factory }, C)
  507. bench.erased.register({ name: 't.rows', id: 'a', store: factory }, C)
  508. expect(factory).toHaveBeenCalledTimes(2)
  509. const a = host.storeOf(host.entriesOf('t.host')[0] as never, undefined)
  510. const b = host.storeOf(host.entriesOf('t.rows')[0] as never, undefined)
  511. expect(a).not.toBe(b) // two mints, two instances
  512. })
  513. it('drops instances with the last holding entry and refuses stale resolution', async () => {
  514. const { bench, host } = await storeBench()
  515. const { handle } = fakeHandle()
  516. const d1 = bench.erased.register({ name: 't.host', store: handle }, C)
  517. bench.erased.register({ name: 't.rows', id: 'a', store: handle }, C)
  518. const rowEntry = host.entriesOf('t.rows')[0]
  519. const hostEntry = host.entriesOf('t.host')[0]
  520. const shared = host.storeOf(rowEntry as never, undefined)
  521. d1() // one holder left: record (and instance) survive
  522. expect(host.storeOf(rowEntry as never, undefined)).toBe(shared)
  523. expect(() => host.storeOf(hostEntry as never, undefined)).not.toThrow() // handle still live via the row entry
  524. // Note: dropping the row entry would sever the last reference; stale
  525. // resolution is covered through the cascade spec below.
  526. })
  527. it('pruneStoreScope clears persisted state per dead session, including never-materialized ones', async () => {
  528. const { bench, host } = await storeBench()
  529. const { handle, created } = fakeHandle()
  530. bench.erased.register({ name: 't.panel', store: handle }, C)
  531. const [entry] = host.entriesOf('t.panel')
  532. const s1 = host.storeOf(entry as never, 's1')
  533. expect(s1).toBe(created[0]) // the resolved instance is the fake the handle minted
  534. bench.svc.pruneStoreScope('s1')
  535. expect(created[0]?.clearPersisted).toHaveBeenCalledTimes(1)
  536. expect(host.storeOf(entry as never, 's1')).not.toBe(s1) // instance dropped, next resolve mints anew
  537. // Never-rendered dead session: a transient instance is created just to clear storage.
  538. const before = created.length
  539. bench.svc.pruneStoreScope('s-never')
  540. expect(created.length).toBe(before + 1)
  541. expect(created[created.length - 1]?.clearPersisted).toHaveBeenCalledTimes(1)
  542. })
  543. })
  544. describe('entry-unload cascade', () => {
  545. it('kills declared children, their contributions, and the ledger rows with the entry', async () => {
  546. const bench = await boot()
  547. let host: SlotRendererHost | undefined
  548. bench.erased.install({
  549. renderRoot: (h: SlotRendererHost) => { host = h; return 'rendered' },
  550. })
  551. bench.ctx.reflect.provide('sessions', fakeSessions())
  552. bench.ctx.reflect.provide('workspaces', fakeWorkspaces())
  553. // The declarer here is NOT the root occupant: root stays occupied by a
  554. // separate entry so disposing the declarer only kills its children.
  555. const disposeRoot = bench.erased.register({ name: 'root' }, C)
  556. bench.erased.renderSlot('root', {})
  557. if (host === undefined) throw new Error('renderer never received the host')
  558. disposeRoot()
  559. const disposeDeclarer = bench.erased.register({
  560. name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
  561. }, C)
  562. bench.erased.register({ name: 't.host' }, C)
  563. const [childEntry] = host.entriesOf('t.host')
  564. expect(childEntry).toBeDefined()
  565. disposeDeclarer()
  566. expect(bench.svc.spec('t.host')).toBeUndefined() // ledger row gone
  567. expect(host.specOf('t.host')).toBeUndefined() // outlets now render empty
  568. expect(bench.svc.entries('t.host')).toHaveLength(0) // contribution cleared
  569. expect(host.isLive(childEntry as never)).toBe(false) // stale bindings will throw upstream
  570. // The freed key is re-declarable by a new entry (no residue).
  571. expect(() => bench.erased.register({
  572. name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
  573. }, C)).not.toThrow()
  574. })
  575. it('cascades through cordis fiber disposal (plugin unload = full cleanup)', async () => {
  576. const bench = await boot()
  577. bench.erased.register({
  578. name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
  579. }, C)
  580. const fiber = bench.ctx.plugin({
  581. name: 'occupant',
  582. inject: ['slots'],
  583. apply: (pluginCtx: Context) => {
  584. ;(pluginCtx.slots as unknown as ErasedService).register({ name: 't.host' }, C)
  585. },
  586. })
  587. await fiber.await()
  588. expect(bench.svc.entries('t.host')).toHaveLength(1)
  589. await fiber.dispose()
  590. expect(bench.svc.entries('t.host')).toHaveLength(0)
  591. expect(bench.svc.spec('t.host')).toBeDefined() // declarer still live; slot stays declared
  592. })
  593. it('disposer is idempotent (stale second call is a no-op)', async () => {
  594. const bench = await boot()
  595. const dispose = bench.erased.register({
  596. name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
  597. }, C)
  598. dispose()
  599. expect(() => { dispose() }).not.toThrow()
  600. expect(() => bench.erased.register({
  601. name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
  602. }, C)).not.toThrow()
  603. })
  604. })
  605. describe('event bridge', () => {
  606. it("re-emits entry writes and child declarations as 'slots/changed'", async () => {
  607. const bench = await boot()
  608. const seen: string[] = []
  609. bench.ctx.on('slots/changed', (key) => { seen.push(key) })
  610. bench.erased.register({
  611. name: 'root', children: { 't.rows': { kind: 'list', scope: 'root' } },
  612. }, C)
  613. bench.erased.register({ name: 't.rows', id: 'a' }, C)
  614. expect(seen).toEqual(['root', 't.rows', 't.rows'])
  615. })
  616. })