client-flow.client.spec.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236
  1. // @vitest-environment jsdom
  2. import { Context } from '@deepseek-ai/cordis'
  3. import { describe, expect, it, vi } from 'vitest'
  4. import { act, cleanup, render } from '@testing-library/react'
  5. import { afterEach } from 'vitest'
  6. import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
  7. import type { DirectoryFlowOwnerProps } from '@deepseek-ai/dsh-client-ui-workspace/client'
  8. import { apply, inject } from '../src/client/index.ts'
  9. import { NativeDirectoryFlow } from '../src/client/flow.ts'
  10. import { apply as nodeApply } from '../src/index.ts'
  11. afterEach(cleanup)
  12. const HOLES = ['conversation.hero.workspace.directoryFlow', 'sidebar.workspaces.directoryFlow'] as const
  13. async function bench() {
  14. const ctx = new Context()
  15. await ctx.plugin(SlotRegistry).await()
  16. const pickDirectory = vi.fn(async (): Promise<string | null> => '/tmp/picked')
  17. ctx.provide('uiWorkspace', { pickDirectory } as never)
  18. const slots = ctx.get('slots') as SlotRegistry
  19. const declare = () => slots.register({
  20. name: 'root',
  21. children: Object.fromEntries(HOLES.map(name => [name, { kind: 'single', scope: 'root' }])),
  22. } as never, () => null)
  23. return { ctx, slots, pickDirectory, declare }
  24. }
  25. function owner(overrides: Partial<DirectoryFlowOwnerProps> = {}): DirectoryFlowOwnerProps {
  26. return {
  27. open: true, busy: false,
  28. onPicked: vi.fn(), onCancel: vi.fn(), onError: vi.fn(),
  29. ...overrides,
  30. }
  31. }
  32. describe('directory-picker-native client half', () => {
  33. it('declares the services it drives', () => {
  34. expect(inject).toEqual(['slots', 'uiWorkspace'])
  35. })
  36. it('fills both directory-flow holes for declarations before or after apply, and leaves with its fiber', async () => {
  37. const before = await bench()
  38. before.declare()
  39. const fiber = before.ctx.plugin({ inject: [...inject], apply })
  40. await fiber.await()
  41. for (const hole of HOLES) expect(before.slots.entries(hole)).toHaveLength(1)
  42. // Registry-contribution disposal proof: the fiber going down empties the holes.
  43. await fiber.dispose()
  44. for (const hole of HOLES) expect(before.slots.entries(hole)).toHaveLength(0)
  45. const after = await bench()
  46. await after.ctx.plugin({ inject: [...inject], apply }).await()
  47. for (const hole of HOLES) expect(after.slots.entries(hole)).toHaveLength(0)
  48. after.declare()
  49. await Promise.resolve()
  50. for (const hole of HOLES) expect(after.slots.entries(hole)).toHaveLength(1)
  51. })
  52. it('fails loudly instead of deduplicating a duplicate package row', async () => {
  53. const b = await bench()
  54. b.declare()
  55. await b.ctx.plugin({ inject: [...inject], apply }).await()
  56. const duplicate = b.ctx.plugin({ inject: [...inject], apply })
  57. await expect(duplicate.await()).rejects.toThrow(/already has a registration/)
  58. for (const hole of HOLES) expect(b.slots.entries(hole)).toHaveLength(1)
  59. })
  60. it('rolls back wholesale and reports loudly when a rival injection wins declaration activation', async () => {
  61. const b = await bench()
  62. const rejections: unknown[] = []
  63. const onUnhandled = (reason: unknown): void => { rejections.push(reason) }
  64. // queueMicrotask throws surface as uncaughtException, not a rejection.
  65. process.on('unhandledRejection', onUnhandled)
  66. process.on('uncaughtException', onUnhandled)
  67. try {
  68. // The rival subscribes first, so synchronous declaration notifications
  69. // let it occupy the pair before this provider's waiting injection runs.
  70. b.slots.inject(HOLES[0], () => b.slots.inject(HOLES[1], function* () {
  71. yield b.slots.register({ name: HOLES[0] } as never, () => null)
  72. yield b.slots.register({ name: HOLES[1] } as never, () => null)
  73. }))
  74. await b.ctx.plugin({ inject: [...inject], apply }).await()
  75. b.declare()
  76. await new Promise(resolve => setTimeout(resolve, 20))
  77. // The rival keeps both holes; this provider rolled back wholesale and
  78. // surfaced the conflict on the fail-loud channel — no partial mix.
  79. for (const hole of HOLES) expect(b.slots.entries(hole)).toHaveLength(1)
  80. expect(rejections.map(String).join('\n')).toContain('already has a registration')
  81. // Non-Error conflicts wrap before the loud rethrow (same channel).
  82. const c = await bench()
  83. await c.ctx.plugin({ inject: [...inject], apply }).await()
  84. const original = c.slots.register.bind(c.slots)
  85. const slotsAny = c.slots as { register: typeof original }
  86. slotsAny.register = ((options: never, component: never) => {
  87. if ((options as { name?: string }).name === HOLES[0]) throw 'string conflict'
  88. return original(options, component)
  89. }) as typeof original
  90. c.declare()
  91. await new Promise(resolve => setTimeout(resolve, 20))
  92. expect(rejections.map(String).join('\n')).toContain('string conflict')
  93. } finally {
  94. process.off('unhandledRejection', onUnhandled)
  95. process.off('uncaughtException', onUnhandled)
  96. }
  97. })
  98. it('rolls back the outer injection when the second hole is already occupied', async () => {
  99. const b = await bench()
  100. b.declare()
  101. // Foreign occupant in the SECOND registered hole: the pair construction
  102. // throws after the outer injection installed its subscription.
  103. b.slots.register({ name: HOLES[1] } as never, () => null)
  104. const rejections: unknown[] = []
  105. const onUnhandled = (reason: unknown): void => { rejections.push(reason) }
  106. process.on('unhandledRejection', onUnhandled)
  107. try {
  108. const fiber = b.ctx.plugin({ inject: [...inject], apply })
  109. await expect(fiber.await()).rejects.toThrow(/already has a registration/)
  110. // A leaked first deferral would now race this probe registration and
  111. // throw from its orphaned subscription against the HERO hole; the
  112. // rollback leaves only the activation failure itself (cordis re-raises
  113. // the apply throw as a late rejection — installFailLoud's contract).
  114. const disposeProbe = b.slots.register({ name: HOLES[0] } as never, () => null)
  115. await new Promise(resolve => setTimeout(resolve, 20))
  116. expect(rejections.map(String).filter(text => text.includes(HOLES[0]))).toEqual([])
  117. disposeProbe()
  118. } finally {
  119. process.off('unhandledRejection', onUnhandled)
  120. }
  121. })
  122. it('rejects a second flow occupant at load (single-kind hole)', async () => {
  123. const b = await bench()
  124. b.declare()
  125. await b.ctx.plugin({ inject: [...inject], apply }).await()
  126. expect(() => b.slots.register({ name: HOLES[0] } as never, () => null))
  127. .toThrow(/already has a registration/)
  128. })
  129. it('drives the injected pick through the hole entry and reports the picked path', async () => {
  130. const b = await bench()
  131. b.declare()
  132. await b.ctx.plugin({ inject: [...inject], apply }).await()
  133. const entry = b.slots.entries(HOLES[0])[0]!
  134. const injected = (entry.inject as () => { pick: () => Promise<string | null> })()
  135. await expect(injected.pick()).resolves.toBe('/tmp/picked')
  136. expect(b.pickDirectory).toHaveBeenCalledOnce()
  137. })
  138. it('runs one pick per open edge and reports the path to the latest onPicked', async () => {
  139. let resolve!: (path: string | null) => void
  140. const pick = vi.fn(() => new Promise<string | null>((settle) => { resolve = settle }))
  141. const first = owner()
  142. const view = render(<NativeDirectoryFlow {...first} pick={pick} />)
  143. expect(pick).toHaveBeenCalledOnce()
  144. // Re-renders while open (busy flips, handler identity changes) must not relaunch the chooser.
  145. const second = owner()
  146. view.rerender(<NativeDirectoryFlow {...second} busy pick={pick} />)
  147. expect(pick).toHaveBeenCalledOnce()
  148. // Even a fresh injected face (re-registration re-runs the inject factory)
  149. // must not relaunch while the same request is still open.
  150. const replacedPick = vi.fn(() => new Promise<string | null>(() => {}))
  151. view.rerender(<NativeDirectoryFlow {...second} busy pick={replacedPick} />)
  152. expect(replacedPick).not.toHaveBeenCalled()
  153. await act(async () => { resolve('/tmp/project') })
  154. expect(second.onPicked).toHaveBeenCalledWith('/tmp/project')
  155. expect(first.onPicked).not.toHaveBeenCalled()
  156. })
  157. it('discards a settlement that lands after the flow unmounted', async () => {
  158. let resolve!: (path: string | null) => void
  159. const pick = vi.fn(() => new Promise<string | null>((settle) => { resolve = settle }))
  160. const props = owner()
  161. const view = render(<NativeDirectoryFlow {...props} pick={pick} />)
  162. expect(pick).toHaveBeenCalledOnce()
  163. view.unmount()
  164. // The dead instance must neither adopt nor error; the owner's callbacks
  165. // stay untouched by the orphaned chooser's answer.
  166. await act(async () => { resolve('/tmp/late') })
  167. expect(props.onPicked).not.toHaveBeenCalled()
  168. expect(props.onCancel).not.toHaveBeenCalled()
  169. expect(props.onError).not.toHaveBeenCalled()
  170. // The failure arm is discarded the same way.
  171. let reject!: (reason: unknown) => void
  172. const failing = vi.fn(() => new Promise<string | null>((_settle, rejectPick) => { reject = rejectPick }))
  173. const late = owner()
  174. const failingView = render(<NativeDirectoryFlow {...late} pick={failing} />)
  175. failingView.unmount()
  176. await act(async () => { reject(new Error('too late')) })
  177. expect(late.onError).not.toHaveBeenCalled()
  178. })
  179. it('reports null as cancellation and re-arms after the owner withdraws open', async () => {
  180. const pick = vi.fn(async () => null as string | null)
  181. const props = owner()
  182. const view = render(<NativeDirectoryFlow {...props} pick={pick} />)
  183. await act(async () => {})
  184. expect(props.onCancel).toHaveBeenCalledOnce()
  185. expect(props.onPicked).not.toHaveBeenCalled()
  186. // Withdraw and reopen: a fresh request runs a fresh pick.
  187. view.rerender(<NativeDirectoryFlow {...props} open={false} pick={pick} />)
  188. view.rerender(<NativeDirectoryFlow {...props} pick={pick} />)
  189. await act(async () => {})
  190. expect(pick).toHaveBeenCalledTimes(2)
  191. })
  192. it('folds pick failures into onError messages', async () => {
  193. const props = owner()
  194. render(<NativeDirectoryFlow {...props} pick={vi.fn(async () => { throw new Error('no chooser installed') })} />)
  195. await act(async () => {})
  196. expect(props.onError).toHaveBeenCalledWith('no chooser installed')
  197. const nonError = owner()
  198. render(<NativeDirectoryFlow {...nonError} pick={vi.fn(async () => { throw 'denied' })} />)
  199. await act(async () => {})
  200. expect(nonError.onError).toHaveBeenCalledWith('denied')
  201. })
  202. it('renders nothing while closed and while open', () => {
  203. const closed = render(<NativeDirectoryFlow {...owner({ open: false })} pick={vi.fn(async () => null)} />)
  204. expect(closed.container.innerHTML).toBe('')
  205. const opened = render(<NativeDirectoryFlow {...owner()} pick={vi.fn(async () => null)} />)
  206. expect(opened.container.innerHTML).toBe('')
  207. })
  208. })
  209. describe('directory-picker-native node half', () => {
  210. // The invariant companion is mounted by the vitest-wide invariant host on
  211. // every Context this suite creates; its registration is covered there.
  212. it('the node apply is an inert loader seat', () => {
  213. expect(() => { nodeApply() }).not.toThrow()
  214. })
  215. })