scope.spec.ts 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222
  1. import { describe, expect, expectTypeOf, it } from 'vitest'
  2. import { Context } from '@deepseek-ai/cordis'
  3. import { bindScopeParent, carrierKeyOf, createScope, isScopeCarrier, scopeChainOf, scopeOf, scopeParentOf, scopeTarget } from '@deepseek-ai/dsh-scope'
  4. import type { Scope, Scoped } from '@deepseek-ai/dsh-scope'
  5. declare module '@deepseek-ai/cordis' {
  6. interface Events {
  7. /**
  8. * Test-only event for scope-filtered dispatch.
  9. * @param value - opaque payload recorded by listeners.
  10. * @mode emit
  11. */
  12. 'scope-test/ping'(value: string): void
  13. }
  14. }
  15. /** Mount a host plugin and mint a scope inside it. */
  16. async function mintScope(ctx: Context, key: object): Promise<Scope> {
  17. let scope!: Scope
  18. await ctx.plugin((inner: Context) => { scope = createScope(inner, key) })
  19. return scope
  20. }
  21. describe('createScope', () => {
  22. it('tags contexts and derived contexts, with the nearest tag winning', async () => {
  23. const ctx = new Context()
  24. const outerKey = { name: 'outer' }
  25. const innerKey = { name: 'inner' }
  26. const outer = await mintScope(ctx, outerKey)
  27. const inner = createScope(outer.ctx, innerKey)
  28. expect(scopeOf(ctx)).toBeUndefined()
  29. expect(scopeOf(outer.ctx)).toBe(outerKey)
  30. expect(scopeOf(outer.ctx.extend({}))).toBe(outerKey)
  31. expect(scopeOf(inner.ctx)).toBe(innerKey)
  32. await inner.dispose()
  33. await outer.dispose()
  34. })
  35. it('is usable synchronously before the backing fiber activates', async () => {
  36. const ctx = new Context()
  37. const events: string[] = []
  38. let scope!: Scope
  39. await ctx.plugin((inner: Context) => {
  40. scope = createScope(inner, { name: 'sync' })
  41. scope.ctx.effect(() => () => void events.push('disposed'))
  42. events.push('registered')
  43. })
  44. expect(events).toEqual(['registered'])
  45. await scope.dispose()
  46. expect(events).toEqual(['registered', 'disposed'])
  47. })
  48. it('shares quiescence across repeat and raw-disposer-first calls', async () => {
  49. const ctx = new Context()
  50. const scope = await mintScope(ctx, { name: 'quiescence' })
  51. const gate = Promise.withResolvers<undefined>()
  52. let finished = false
  53. scope.ctx.effect(() => async () => {
  54. await gate.promise
  55. finished = true
  56. })
  57. const raw = Promise.resolve(scope.rawDispose())
  58. const publicDispose = scope.dispose()
  59. await Promise.resolve()
  60. expect(finished).toBe(false)
  61. gate.resolve(undefined)
  62. await Promise.all([raw, publicDispose, scope.dispose()])
  63. expect(finished).toBe(true)
  64. })
  65. it('exposes the exact raw disposer for ordered composite teardown', async () => {
  66. const ctx = new Context()
  67. const order: string[] = []
  68. let dispose!: () => Promise<void> | void
  69. await ctx.plugin((inner: Context) => {
  70. dispose = inner.effect(function* () {
  71. yield () => void order.push('outer')
  72. const scope = createScope(inner, { name: 'nested' })
  73. scope.ctx.effect(() => () => void order.push('scope'))
  74. yield scope.rawDispose
  75. yield () => void order.push('inner')
  76. })
  77. })
  78. await dispose()
  79. expect(order).toEqual(['inner', 'scope', 'outer'])
  80. })
  81. })
  82. describe('scopeTarget', () => {
  83. it('routes scoped listeners by key while untagged listeners remain global', async () => {
  84. const ctx = new Context()
  85. const keyA = { name: 'A' }
  86. const keyB = { name: 'B' }
  87. const scopeA = await mintScope(ctx, keyA)
  88. const scopeB = await mintScope(ctx, keyB)
  89. const heard: string[] = []
  90. ctx.on('scope-test/ping', value => void heard.push(`global:${value}`))
  91. scopeA.ctx.on('scope-test/ping', value => void heard.push(`A:${value}`))
  92. scopeB.ctx.on('scope-test/ping', value => void heard.push(`B:${value}`))
  93. ctx.emit(scopeTarget(ctx, keyA), 'scope-test/ping', 'a')
  94. ctx.emit(scopeTarget(ctx, keyB), 'scope-test/ping', 'b')
  95. ctx.emit(scopeTarget(ctx, undefined), 'scope-test/ping', 'none')
  96. expect(heard).toEqual(['global:a', 'A:a', 'global:b', 'B:b', 'global:none'])
  97. await Promise.all([scopeA.dispose(), scopeB.dispose()])
  98. })
  99. it('preserves a base Cordis filter and its receiver', async () => {
  100. const ctx = new Context()
  101. const key = { name: 'A' }
  102. const scope = await mintScope(ctx, key)
  103. const heard: string[] = []
  104. ctx.on('scope-test/ping', value => void heard.push(`global:${value}`))
  105. scope.ctx.on('scope-test/ping', value => void heard.push(`A:${value}`))
  106. let receiverMatches = false
  107. const base = {
  108. [Context.filter](this: object): boolean {
  109. receiverMatches = this === base
  110. return false
  111. },
  112. }
  113. ctx.emit(scopeTarget(base, key), 'scope-test/ping', 'vetoed')
  114. expect(heard).toEqual([])
  115. expect(receiverMatches).toBe(true)
  116. await scope.dispose()
  117. })
  118. it('{ global: true } listeners retain Cordis global-listener semantics', async () => {
  119. const ctx = new Context()
  120. const scope = await mintScope(ctx, { name: 'A' })
  121. const heard: string[] = []
  122. scope.ctx.on('scope-test/ping', value => void heard.push(value), { global: true })
  123. ctx.emit(scopeTarget(ctx, { name: 'other' }), 'scope-test/ping', 'foreign')
  124. ctx.emit(scopeTarget(ctx, undefined), 'scope-test/ping', 'none')
  125. expect(heard).toEqual(['foreign', 'none'])
  126. await scope.dispose()
  127. })
  128. it('uses an opaque branded carrier with a separately tracked key', () => {
  129. const key = { name: 'key' }
  130. const subject = { value: 1 }
  131. const carrier = scopeTarget(subject, key)
  132. expect(isScopeCarrier(carrier)).toBe(true)
  133. expect(carrierKeyOf(carrier)).toBe(key)
  134. expect(isScopeCarrier(subject)).toBe(false)
  135. expect(carrierKeyOf(subject)).toBeUndefined()
  136. expect('value' in carrier).toBe(false)
  137. expectTypeOf(carrier).toEqualTypeOf<Scoped<typeof subject>>()
  138. })
  139. })
  140. describe('scope parent chain', () => {
  141. it('links at mint, walks to the root, and rejects cycles', () => {
  142. const ctx = new Context()
  143. const preset = { kind: 'preset' }
  144. const agent = { kind: 'agent' }
  145. createScope(ctx, preset)
  146. createScope(ctx, agent, { parent: preset })
  147. expect(scopeParentOf(agent)).toBe(preset)
  148. expect(scopeParentOf(preset)).toBeUndefined()
  149. expect(scopeChainOf(agent)).toEqual([agent, preset])
  150. expect(scopeChainOf(undefined)).toEqual([])
  151. expect(() => { bindScopeParent(preset, agent) }).toThrow(/cycle/)
  152. expect(() => { bindScopeParent(preset, preset) }).toThrow(/cycle/)
  153. })
  154. it('re-links only through the binding held by the original binder', () => {
  155. const ctx = new Context()
  156. const presetA = { id: 'a' }
  157. const presetB = { id: 'b' }
  158. const agent = { id: 'agent' }
  159. createScope(ctx, presetA)
  160. createScope(ctx, presetB)
  161. const binding = bindScopeParent(agent, presetA)
  162. createScope(ctx, agent)
  163. // A bound key cannot be re-bound from the outside; only the binding moves it.
  164. expect(() => bindScopeParent(agent, presetB)).toThrow(/already bound/)
  165. binding.rebind(presetB)
  166. expect(scopeChainOf(agent)).toEqual([agent, presetB])
  167. // The rebind keeps the cycle check: a parent may not adopt its ancestor.
  168. const child = { id: 'child' }
  169. const childBinding = bindScopeParent(child, agent)
  170. void childBinding
  171. expect(() => { binding.rebind(child) }).toThrow(/cycle/)
  172. })
  173. it('admits an ancestor-tagged listener for a descendant dispatch, never the reverse', () => {
  174. const ctx = new Context()
  175. const preset = { kind: 'preset' }
  176. const agent = { kind: 'agent' }
  177. const other = { kind: 'other-preset' }
  178. const presetScope = createScope(ctx, preset)
  179. const agentScope = createScope(ctx, agent, { parent: preset })
  180. const otherScope = createScope(ctx, other)
  181. const seen: string[] = []
  182. ctx.on('probe/event' as never, ((): void => { seen.push('untagged') }) as never)
  183. presetScope.ctx.on('probe/event' as never, ((): void => { seen.push('preset') }) as never)
  184. agentScope.ctx.on('probe/event' as never, ((): void => { seen.push('agent') }) as never)
  185. otherScope.ctx.on('probe/event' as never, ((): void => { seen.push('other') }) as never)
  186. const emit = ctx as unknown as { emit: (carrier: object, type: string) => void }
  187. // Dispatch at the AGENT key: its own tag and its ancestor's admit; a
  188. // sibling root does not.
  189. emit.emit(scopeTarget({}, agent), 'probe/event')
  190. expect(seen.sort()).toEqual(['agent', 'preset', 'untagged'])
  191. // Dispatch at the PRESET key: the agent-tagged listener sits BELOW the
  192. // dispatch key and stays excluded — events flow up the chain, not down.
  193. seen.length = 0
  194. emit.emit(scopeTarget({}, preset), 'probe/event')
  195. expect(seen.sort()).toEqual(['preset', 'untagged'])
  196. })
  197. })