stale-authorization.spec.tsx 5.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136
  1. // @vitest-environment jsdom
  2. /**
  3. * Stale renderSlot bindings (slot terminal design §9): a binding dies with
  4. * its entry — a retained closure invoked after the entry's disposal throws
  5. * StaleAuthorizationError off the ledger check, and an HMR-style reload (new
  6. * entry, same key) mints a NEW binding rather than reviving the old one.
  7. */
  8. import { describe, expect, it } from 'vitest'
  9. import { act, render } from '@testing-library/react'
  10. import type { ReactNode } from 'react'
  11. import type { SlotEntryDef, SlotSpec, StoredEntry } from '@deepseek-ai/dsh-client-ui-slots'
  12. import {
  13. createSlotRenderer, StaleAuthorizationError,
  14. type RenderOpts, type SlotRendererHost,
  15. } from '@deepseek-ai/dsh-client-web-react'
  16. type RenderSlotFn = (key: string, owner: object, opts?: RenderOpts) => ReactNode
  17. type DeclaredSpec = SlotSpec<SlotEntryDef>
  18. /** Ledger-shaped fake: add/dispose maintain the live set the way the runtime ledger does. */
  19. function makeHost() {
  20. const entries = new Map<string, StoredEntry[]>()
  21. const versions = new Map<string, number>()
  22. const subs = new Map<string, Set<() => void>>()
  23. const live = new Set<StoredEntry>()
  24. const bump = (key: string) => {
  25. versions.set(key, (versions.get(key) ?? 0) + 1)
  26. for (const fn of [...(subs.get(key) ?? [])]) fn()
  27. }
  28. const host: SlotRendererHost = {
  29. subscribe: (key, fn) => {
  30. const set = subs.get(key) ?? new Set()
  31. set.add(fn)
  32. subs.set(key, set)
  33. return () => { set.delete(fn) }
  34. },
  35. getVersion: (key) => versions.get(key) ?? 0,
  36. entriesOf: (key) => entries.get(key) ?? [],
  37. specOf: () => ({ kind: 'single', scope: 'root' }),
  38. isLive: (entry) => live.has(entry),
  39. storeOf: () => undefined,
  40. sessions: {
  41. list: { getSnapshot: () => ({}), subscribe: () => () => {} },
  42. current: { getSnapshot: () => undefined, subscribe: () => () => {} },
  43. cell: () => undefined,
  44. },
  45. }
  46. return {
  47. host,
  48. add: (key: string, entry: StoredEntry) => {
  49. entries.set(key, [...(entries.get(key) ?? []), entry])
  50. live.add(entry)
  51. bump(key)
  52. return () => {
  53. entries.set(key, (entries.get(key) ?? []).filter((e) => e !== entry))
  54. live.delete(entry)
  55. bump(key)
  56. }
  57. },
  58. }
  59. }
  60. const CHILD: DeclaredSpec = { kind: 'single', scope: 'root' }
  61. /**
  62. * Mount a root entry that leaks its binding to the test, then render. The
  63. * returned dispose unmounts the view FIRST: an empty 'root' makes the live
  64. * root outlet rethrow its boot-order failure (fail-loud, covered in the
  65. * scoped-slots suite); the retained-closure scenario under test here is a
  66. * dead entry whose binding outlives the tree.
  67. */
  68. function mountCapturing(h: ReturnType<typeof makeHost>) {
  69. let captured: RenderSlotFn | undefined
  70. const entry: StoredEntry = {
  71. component: (props: { renderSlot: RenderSlotFn }) => {
  72. captured = props.renderSlot
  73. return null
  74. },
  75. options: {},
  76. children: { 'k.child': CHILD },
  77. }
  78. const disposeEntry = h.add('root', entry)
  79. const view = render(<>{createSlotRenderer().renderRoot(h.host, {})}</>)
  80. return {
  81. binding: captured!,
  82. entry,
  83. dispose: () => {
  84. view.unmount()
  85. disposeEntry()
  86. },
  87. }
  88. }
  89. describe('stale authorization', () => {
  90. it('a live binding renders; the same closure throws after its entry is disposed', () => {
  91. const h = makeHost()
  92. const { binding, dispose } = mountCapturing(h)
  93. expect(binding('k.child', {})).not.toBeUndefined() // live: returns an element
  94. act(() => { dispose() })
  95. expect(() => binding('k.child', {})).toThrow(StaleAuthorizationError)
  96. expect(() => binding('k.child', {})).toThrow(/disposed registration/)
  97. })
  98. it('stale check precedes the ownership check: a dead binding throws stale even for undeclared keys', () => {
  99. const h = makeHost()
  100. const { binding, dispose } = mountCapturing(h)
  101. act(() => { dispose() })
  102. // Were ownership checked first this would be SlotOwnershipError; the dead
  103. // entry must fail on liveness regardless of the key asked for.
  104. expect(() => binding('k.undeclared', {})).toThrow(StaleAuthorizationError)
  105. })
  106. it('HMR reload (same key, new entry) mints a fresh binding; the old one stays dead', () => {
  107. const h = makeHost()
  108. const first = mountCapturing(h)
  109. act(() => { first.dispose() })
  110. // Reload: a new entry object for the same slot key (new registration identity).
  111. let secondBinding: RenderSlotFn | undefined
  112. const secondEntry: StoredEntry = {
  113. component: (props: { renderSlot: RenderSlotFn }) => {
  114. secondBinding = props.renderSlot
  115. return null
  116. },
  117. options: {},
  118. children: { 'k.child': CHILD },
  119. }
  120. h.add('root', secondEntry)
  121. render(<>{createSlotRenderer().renderRoot(h.host, {})}</>)
  122. expect(secondBinding).toBeDefined()
  123. expect(secondBinding).not.toBe(first.binding) // new identity, no revival
  124. expect(secondBinding!('k.child', {})).not.toBeUndefined() // new binding is live
  125. expect(() => first.binding('k.child', {})).toThrow(StaleAuthorizationError) // old stays dead
  126. })
  127. })