client-flow.client.spec.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292
  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. const desktopIpc = await vi.hoisted(async () => {
  12. const { createRequire } = await import('node:module')
  13. const path = await import('node:path')
  14. // Electron belongs to the Desktop app; resolve its mock from that workspace.
  15. const electron = createRequire(path.resolve(import.meta.dirname, '../../../../apps/desktop/package.json')).resolve('electron')
  16. return { invoke: vi.fn(), electron }
  17. })
  18. vi.mock(desktopIpc.electron, () => ({
  19. ipcRenderer: { invoke: desktopIpc.invoke },
  20. contextBridge: { exposeInMainWorld: (name: string, value: unknown) => { vi.stubGlobal(name, value) } },
  21. }))
  22. vi.mock('../../../../apps/desktop/src/preload-platform.ts', () => ({ markDocumentPlatform: vi.fn() }))
  23. vi.mock('../../../../apps/desktop/src/preload-theme.ts', () => ({ syncNativeTheme: vi.fn() }))
  24. vi.mock('../../../../apps/desktop/src/preload-windows.ts', () => ({ syncWindowsAppearance: vi.fn() }))
  25. afterEach(() => { cleanup(); vi.unstubAllGlobals() })
  26. const HOLES = ['conversation.hero.workspace.directoryFlow', 'sidebar.workspaces.directoryFlow'] as const
  27. async function bench() {
  28. const ctx = new Context()
  29. await ctx.plugin(SlotRegistry).await()
  30. const pickDirectory = vi.fn(async (): Promise<string | null> => '/tmp/picked')
  31. ctx.provide('uiWorkspace', { pickDirectory } as never)
  32. const slots = ctx.get('slots') as SlotRegistry
  33. const declare = () => slots.register({
  34. name: 'root',
  35. children: Object.fromEntries(HOLES.map(name => [name, { kind: 'single', scope: 'root' }])),
  36. } as never, () => null)
  37. return { ctx, slots, pickDirectory, declare }
  38. }
  39. function owner(overrides: Partial<DirectoryFlowOwnerProps> = {}): DirectoryFlowOwnerProps {
  40. return {
  41. open: true, busy: false,
  42. onPicked: vi.fn(), onCancel: vi.fn(), onError: vi.fn(),
  43. ...overrides,
  44. }
  45. }
  46. describe('directory-picker-native client half', () => {
  47. it('declares the services it drives', () => {
  48. expect(inject).toEqual(['slots', 'uiWorkspace'])
  49. })
  50. it('fills both directory-flow holes for declarations before or after apply, and leaves with its fiber', async () => {
  51. const before = await bench()
  52. before.declare()
  53. const fiber = before.ctx.plugin({ inject: [...inject], apply })
  54. await fiber.await()
  55. for (const hole of HOLES) expect(before.slots.entries(hole)).toHaveLength(1)
  56. // Registry-contribution disposal proof: the fiber going down empties the holes.
  57. await fiber.dispose()
  58. for (const hole of HOLES) expect(before.slots.entries(hole)).toHaveLength(0)
  59. const after = await bench()
  60. await after.ctx.plugin({ inject: [...inject], apply }).await()
  61. for (const hole of HOLES) expect(after.slots.entries(hole)).toHaveLength(0)
  62. after.declare()
  63. await Promise.resolve()
  64. for (const hole of HOLES) expect(after.slots.entries(hole)).toHaveLength(1)
  65. })
  66. it('fails loudly instead of deduplicating a duplicate package row', async () => {
  67. const b = await bench()
  68. b.declare()
  69. await b.ctx.plugin({ inject: [...inject], apply }).await()
  70. const duplicate = b.ctx.plugin({ inject: [...inject], apply })
  71. await expect(duplicate.await()).rejects.toThrow(/already has a registration/)
  72. for (const hole of HOLES) expect(b.slots.entries(hole)).toHaveLength(1)
  73. })
  74. it('rolls back wholesale and reports loudly when a rival injection wins declaration activation', async () => {
  75. const b = await bench()
  76. const rejections: unknown[] = []
  77. const onUnhandled = (reason: unknown): void => { rejections.push(reason) }
  78. // queueMicrotask throws surface as uncaughtException, not a rejection.
  79. process.on('unhandledRejection', onUnhandled)
  80. process.on('uncaughtException', onUnhandled)
  81. try {
  82. // The rival subscribes first, so synchronous declaration notifications
  83. // let it occupy the pair before this provider's waiting injection runs.
  84. b.slots.inject(HOLES[0], () => b.slots.inject(HOLES[1], function* () {
  85. yield b.slots.register({ name: HOLES[0] } as never, () => null)
  86. yield b.slots.register({ name: HOLES[1] } as never, () => null)
  87. }))
  88. await b.ctx.plugin({ inject: [...inject], apply }).await()
  89. b.declare()
  90. await new Promise(resolve => setTimeout(resolve, 20))
  91. // The rival keeps both holes; this provider rolled back wholesale and
  92. // surfaced the conflict on the fail-loud channel — no partial mix.
  93. for (const hole of HOLES) expect(b.slots.entries(hole)).toHaveLength(1)
  94. expect(rejections.map(String).join('\n')).toContain('already has a registration')
  95. // Non-Error conflicts wrap before the loud rethrow (same channel).
  96. const c = await bench()
  97. await c.ctx.plugin({ inject: [...inject], apply }).await()
  98. const original = c.slots.register.bind(c.slots)
  99. const slotsAny = c.slots as { register: typeof original }
  100. slotsAny.register = ((options: never, component: never) => {
  101. if ((options as { name?: string }).name === HOLES[0]) throw 'string conflict'
  102. return original(options, component)
  103. }) as typeof original
  104. c.declare()
  105. await new Promise(resolve => setTimeout(resolve, 20))
  106. expect(rejections.map(String).join('\n')).toContain('string conflict')
  107. } finally {
  108. process.off('unhandledRejection', onUnhandled)
  109. process.off('uncaughtException', onUnhandled)
  110. }
  111. })
  112. it('rolls back the outer injection when the second hole is already occupied', async () => {
  113. const b = await bench()
  114. b.declare()
  115. // Foreign occupant in the SECOND registered hole: the pair construction
  116. // throws after the outer injection installed its subscription.
  117. b.slots.register({ name: HOLES[1] } as never, () => null)
  118. const rejections: unknown[] = []
  119. const onUnhandled = (reason: unknown): void => { rejections.push(reason) }
  120. process.on('unhandledRejection', onUnhandled)
  121. try {
  122. const fiber = b.ctx.plugin({ inject: [...inject], apply })
  123. await expect(fiber.await()).rejects.toThrow(/already has a registration/)
  124. // A leaked first deferral would now race this probe registration and
  125. // throw from its orphaned subscription against the HERO hole; the
  126. // rollback leaves only the activation failure itself (cordis re-raises
  127. // the apply throw as a late rejection — installFailLoud's contract).
  128. const disposeProbe = b.slots.register({ name: HOLES[0] } as never, () => null)
  129. await new Promise(resolve => setTimeout(resolve, 20))
  130. expect(rejections.map(String).filter(text => text.includes(HOLES[0]))).toEqual([])
  131. disposeProbe()
  132. } finally {
  133. process.off('unhandledRejection', onUnhandled)
  134. }
  135. })
  136. it('rejects a second flow occupant at load (single-kind hole)', async () => {
  137. const b = await bench()
  138. b.declare()
  139. await b.ctx.plugin({ inject: [...inject], apply }).await()
  140. expect(() => b.slots.register({ name: HOLES[0] } as never, () => null))
  141. .toThrow(/already has a registration/)
  142. })
  143. it('drives the injected pick through the hole entry and reports the picked path', async () => {
  144. const b = await bench()
  145. b.declare()
  146. await b.ctx.plugin({ inject: [...inject], apply }).await()
  147. const entry = b.slots.entries(HOLES[0])[0]!
  148. const injected = (entry.inject as () => { pick: () => Promise<string | null> })()
  149. await expect(injected.pick()).resolves.toBe('/tmp/picked')
  150. expect(b.pickDirectory).toHaveBeenCalledOnce()
  151. })
  152. it('consumes the actual Desktop preload bridge and sends its directory-pick IPC', async () => {
  153. vi.stubGlobal('location', new URL('dsh-app://app/'))
  154. // Desktop's preload is typechecked by its own compiler program.
  155. const preload = '../../../../apps/desktop/src/preload-app.ts'
  156. await import(/* @vite-ignore */ preload)
  157. desktopIpc.invoke.mockResolvedValue('/desktop/workspace')
  158. const b = await bench()
  159. const dispose = b.declare()
  160. const fiber = b.ctx.plugin({ inject: [...inject], apply })
  161. try {
  162. await fiber.await()
  163. const entry = b.slots.entries(HOLES[0])[0]!
  164. const injected = (entry.inject as () => { pick: () => Promise<string | null> })()
  165. await expect(injected.pick()).resolves.toBe('/desktop/workspace')
  166. expect(desktopIpc.invoke).toHaveBeenCalledExactlyOnceWith('dsh-desktop:directory-pick')
  167. expect(b.pickDirectory).not.toHaveBeenCalled()
  168. } finally {
  169. await fiber.dispose()
  170. dispose()
  171. }
  172. })
  173. it('uses the desktop bridge without calling the Host and preserves cancellation and errors', async () => {
  174. const pick = vi.fn<() => Promise<string | null>>().mockResolvedValue('/desktop/workspace')
  175. vi.stubGlobal('__DSH_DIRECTORY_PICKER__', { pick })
  176. const b = await bench()
  177. b.declare()
  178. const fiber = b.ctx.plugin({ inject: [...inject], apply })
  179. await fiber.await()
  180. try {
  181. const entry = b.slots.entries(HOLES[0])[0]!
  182. const injected = (entry.inject as () => { pick: () => Promise<string | null> })()
  183. await expect(injected.pick()).resolves.toBe('/desktop/workspace')
  184. pick.mockResolvedValue(null)
  185. await expect(injected.pick()).resolves.toBeNull()
  186. pick.mockRejectedValue(new Error('desktop dialog failed'))
  187. await expect(injected.pick()).rejects.toThrow('desktop dialog failed')
  188. expect(b.pickDirectory).not.toHaveBeenCalled()
  189. } finally {
  190. await fiber.dispose()
  191. }
  192. })
  193. it('runs one pick per open edge and reports the path to the latest onPicked', async () => {
  194. let resolve!: (path: string | null) => void
  195. const pick = vi.fn(() => new Promise<string | null>((settle) => { resolve = settle }))
  196. const first = owner()
  197. const view = render(<NativeDirectoryFlow {...first} pick={pick} />)
  198. expect(pick).toHaveBeenCalledOnce()
  199. // Re-renders while open (busy flips, handler identity changes) must not relaunch the chooser.
  200. const second = owner()
  201. view.rerender(<NativeDirectoryFlow {...second} busy pick={pick} />)
  202. expect(pick).toHaveBeenCalledOnce()
  203. // Even a fresh injected face (re-registration re-runs the inject factory)
  204. // must not relaunch while the same request is still open.
  205. const replacedPick = vi.fn(() => new Promise<string | null>(() => {}))
  206. view.rerender(<NativeDirectoryFlow {...second} busy pick={replacedPick} />)
  207. expect(replacedPick).not.toHaveBeenCalled()
  208. await act(async () => { resolve('/tmp/project') })
  209. expect(second.onPicked).toHaveBeenCalledWith('/tmp/project')
  210. expect(first.onPicked).not.toHaveBeenCalled()
  211. })
  212. it('discards a settlement that lands after the flow unmounted', async () => {
  213. let resolve!: (path: string | null) => void
  214. const pick = vi.fn(() => new Promise<string | null>((settle) => { resolve = settle }))
  215. const props = owner()
  216. const view = render(<NativeDirectoryFlow {...props} pick={pick} />)
  217. expect(pick).toHaveBeenCalledOnce()
  218. view.unmount()
  219. // The dead instance must neither adopt nor error; the owner's callbacks
  220. // stay untouched by the orphaned chooser's answer.
  221. await act(async () => { resolve('/tmp/late') })
  222. expect(props.onPicked).not.toHaveBeenCalled()
  223. expect(props.onCancel).not.toHaveBeenCalled()
  224. expect(props.onError).not.toHaveBeenCalled()
  225. // The failure arm is discarded the same way.
  226. let reject!: (reason: unknown) => void
  227. const failing = vi.fn(() => new Promise<string | null>((_settle, rejectPick) => { reject = rejectPick }))
  228. const late = owner()
  229. const failingView = render(<NativeDirectoryFlow {...late} pick={failing} />)
  230. failingView.unmount()
  231. await act(async () => { reject(new Error('too late')) })
  232. expect(late.onError).not.toHaveBeenCalled()
  233. })
  234. it('reports null as cancellation and re-arms after the owner withdraws open', async () => {
  235. const pick = vi.fn(async () => null as string | null)
  236. const props = owner()
  237. const view = render(<NativeDirectoryFlow {...props} pick={pick} />)
  238. await act(async () => {})
  239. expect(props.onCancel).toHaveBeenCalledOnce()
  240. expect(props.onPicked).not.toHaveBeenCalled()
  241. // Withdraw and reopen: a fresh request runs a fresh pick.
  242. view.rerender(<NativeDirectoryFlow {...props} open={false} pick={pick} />)
  243. view.rerender(<NativeDirectoryFlow {...props} pick={pick} />)
  244. await act(async () => {})
  245. expect(pick).toHaveBeenCalledTimes(2)
  246. })
  247. it('folds pick failures into onError messages', async () => {
  248. const props = owner()
  249. render(<NativeDirectoryFlow {...props} pick={vi.fn(async () => { throw new Error('no chooser installed') })} />)
  250. await act(async () => {})
  251. expect(props.onError).toHaveBeenCalledWith('no chooser installed')
  252. const nonError = owner()
  253. render(<NativeDirectoryFlow {...nonError} pick={vi.fn(async () => { throw 'denied' })} />)
  254. await act(async () => {})
  255. expect(nonError.onError).toHaveBeenCalledWith('denied')
  256. })
  257. it('renders nothing while closed and while open', () => {
  258. const closed = render(<NativeDirectoryFlow {...owner({ open: false })} pick={vi.fn(async () => null)} />)
  259. expect(closed.container.innerHTML).toBe('')
  260. const opened = render(<NativeDirectoryFlow {...owner()} pick={vi.fn(async () => null)} />)
  261. expect(opened.container.innerHTML).toBe('')
  262. })
  263. })
  264. describe('directory-picker-native node half', () => {
  265. it('the node apply is an inert loader seat', () => {
  266. expect(() => { nodeApply() }).not.toThrow()
  267. })
  268. })