| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219 |
- // @vitest-environment jsdom
- import { Context } from 'cordis'
- import { describe, expect, it, vi } from 'vitest'
- import { act, cleanup, render } from '@testing-library/react'
- import { afterEach } from 'vitest'
- import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
- import type { DirectoryFlowOwnerProps } from '@deepseek-ai/dsh-client-ui-workspace/client'
- import { apply, inject } from '../src/client/index.ts'
- import { NativeDirectoryFlow } from '../src/client/flow.ts'
- afterEach(cleanup)
- const HOLES = ['conversation.hero.workspace.directoryFlow', 'sidebar.workspaces.directoryFlow'] as const
- async function bench() {
- const ctx = new Context()
- await ctx.plugin(SlotsService).await()
- const pickDirectory = vi.fn(async (): Promise<string | null> => '/tmp/picked')
- ctx.provide('workspaces', { pickDirectory } as never)
- const slots = ctx.get('slots') as SlotsService
- const declare = () => slots.register({
- name: 'root',
- children: Object.fromEntries(HOLES.map(name => [name, { kind: 'single', scope: 'root' }])),
- } as never, () => null)
- return { ctx, slots, pickDirectory, declare }
- }
- function owner(overrides: Partial<DirectoryFlowOwnerProps> = {}): DirectoryFlowOwnerProps {
- return {
- open: true, busy: false,
- onPicked: vi.fn(), onCancel: vi.fn(), onError: vi.fn(),
- ...overrides,
- }
- }
- describe('directory-picker-native client half', () => {
- it('declares the services it drives', () => {
- expect(inject).toEqual(['slots', 'workspaces'])
- })
- it('fills both directory-flow holes for declarations before or after apply, and leaves with its fiber', async () => {
- const before = await bench()
- before.declare()
- const fiber = before.ctx.plugin({ inject: [...inject], apply })
- await fiber.await()
- for (const hole of HOLES) expect(before.slots.entries(hole)).toHaveLength(1)
- // Registry-contribution disposal proof: the fiber going down empties the holes.
- await fiber.dispose()
- for (const hole of HOLES) expect(before.slots.entries(hole)).toHaveLength(0)
- const after = await bench()
- await after.ctx.plugin({ inject: [...inject], apply }).await()
- for (const hole of HOLES) expect(after.slots.entries(hole)).toHaveLength(0)
- after.declare()
- await Promise.resolve()
- for (const hole of HOLES) expect(after.slots.entries(hole)).toHaveLength(1)
- })
- it('rolls back wholesale and reports loudly when a rival provider wins after deferred activation', async () => {
- const b = await bench()
- const rejections: unknown[] = []
- const onUnhandled = (reason: unknown): void => { rejections.push(reason) }
- // queueMicrotask throws surface as uncaughtException, not a rejection.
- process.on('unhandledRejection', onUnhandled)
- process.on('uncaughtException', onUnhandled)
- try {
- // This provider activates BEFORE any hole exists: both deferrals wait.
- // (Duplicate rows of the SAME package converge silently — the deferral
- // skips a hole its own component already occupies; the conflict needs
- // a rival provider.)
- await b.ctx.plugin({ inject: [...inject], apply }).await()
- b.declare()
- // A rival occupies both holes ahead of the pending microtask flush.
- b.slots.register({ name: HOLES[0] } as never, () => null)
- b.slots.register({ name: HOLES[1] } as never, () => null)
- await new Promise(resolve => setTimeout(resolve, 20))
- // The rival keeps both holes; this provider rolled back wholesale and
- // surfaced the conflict on the fail-loud channel — no partial mix.
- for (const hole of HOLES) expect(b.slots.entries(hole)).toHaveLength(1)
- expect(rejections.map(String).join('\n')).toContain('already has a registration')
- // Non-Error conflicts wrap before the loud rethrow (same channel).
- const c = await bench()
- await c.ctx.plugin({ inject: [...inject], apply }).await()
- const original = c.slots.register.bind(c.slots)
- const slotsAny = c.slots as { register: typeof original }
- slotsAny.register = ((options: never, component: never) => {
- if ((options as { name?: string }).name === HOLES[0]) throw 'string conflict'
- return original(options, component)
- }) as typeof original
- c.declare()
- await new Promise(resolve => setTimeout(resolve, 20))
- expect(rejections.map(String).join('\n')).toContain('string conflict')
- } finally {
- process.off('unhandledRejection', onUnhandled)
- process.off('uncaughtException', onUnhandled)
- }
- })
- it('rolls back the first deferral when the second hole is already occupied', async () => {
- const b = await bench()
- b.declare()
- // Foreign occupant in the SECOND registered hole: the pair construction
- // throws after the first deferral installed its subscription.
- b.slots.register({ name: HOLES[1] } as never, () => null)
- const rejections: unknown[] = []
- const onUnhandled = (reason: unknown): void => { rejections.push(reason) }
- process.on('unhandledRejection', onUnhandled)
- try {
- const fiber = b.ctx.plugin({ inject: [...inject], apply })
- await expect(fiber.await()).rejects.toThrow(/already has a registration/)
- // A leaked first deferral would now race this probe registration and
- // throw from its orphaned subscription against the HERO hole; the
- // rollback leaves only the activation failure itself (cordis re-raises
- // the apply throw as a late rejection — installFailLoud's contract).
- const disposeProbe = b.slots.register({ name: HOLES[0] } as never, () => null)
- await new Promise(resolve => setTimeout(resolve, 20))
- expect(rejections.map(String).filter(text => text.includes(HOLES[0]))).toEqual([])
- disposeProbe()
- } finally {
- process.off('unhandledRejection', onUnhandled)
- }
- })
- it('rejects a second flow occupant at load (single-kind hole)', async () => {
- const b = await bench()
- b.declare()
- await b.ctx.plugin({ inject: [...inject], apply }).await()
- expect(() => b.slots.register({ name: HOLES[0] } as never, () => null))
- .toThrow(/already has a registration/)
- })
- it('drives the injected pick through the hole entry and reports the picked path', async () => {
- const b = await bench()
- b.declare()
- await b.ctx.plugin({ inject: [...inject], apply }).await()
- const entry = b.slots.entries(HOLES[0])[0]!
- const injected = (entry.inject as () => { pick: () => Promise<string | null> })()
- await expect(injected.pick()).resolves.toBe('/tmp/picked')
- expect(b.pickDirectory).toHaveBeenCalledOnce()
- })
- it('runs one pick per open edge and reports the path to the latest onPicked', async () => {
- let resolve!: (path: string | null) => void
- const pick = vi.fn(() => new Promise<string | null>((settle) => { resolve = settle }))
- const first = owner()
- const view = render(<NativeDirectoryFlow {...first} pick={pick} />)
- expect(pick).toHaveBeenCalledOnce()
- // Re-renders while open (busy flips, handler identity changes) must not relaunch the chooser.
- const second = owner()
- view.rerender(<NativeDirectoryFlow {...second} busy pick={pick} />)
- expect(pick).toHaveBeenCalledOnce()
- // Even a fresh injected face (re-registration re-runs the inject factory)
- // must not relaunch while the same request is still open.
- const replacedPick = vi.fn(() => new Promise<string | null>(() => {}))
- view.rerender(<NativeDirectoryFlow {...second} busy pick={replacedPick} />)
- expect(replacedPick).not.toHaveBeenCalled()
- await act(async () => { resolve('/tmp/project') })
- expect(second.onPicked).toHaveBeenCalledWith('/tmp/project')
- expect(first.onPicked).not.toHaveBeenCalled()
- })
- it('discards a settlement that lands after the flow unmounted', async () => {
- let resolve!: (path: string | null) => void
- const pick = vi.fn(() => new Promise<string | null>((settle) => { resolve = settle }))
- const props = owner()
- const view = render(<NativeDirectoryFlow {...props} pick={pick} />)
- expect(pick).toHaveBeenCalledOnce()
- view.unmount()
- // The dead instance must neither adopt nor error; the owner's callbacks
- // stay untouched by the orphaned chooser's answer.
- await act(async () => { resolve('/tmp/late') })
- expect(props.onPicked).not.toHaveBeenCalled()
- expect(props.onCancel).not.toHaveBeenCalled()
- expect(props.onError).not.toHaveBeenCalled()
- // The failure arm is discarded the same way.
- let reject!: (reason: unknown) => void
- const failing = vi.fn(() => new Promise<string | null>((_settle, rejectPick) => { reject = rejectPick }))
- const late = owner()
- const failingView = render(<NativeDirectoryFlow {...late} pick={failing} />)
- failingView.unmount()
- await act(async () => { reject(new Error('too late')) })
- expect(late.onError).not.toHaveBeenCalled()
- })
- it('reports null as cancellation and re-arms after the owner withdraws open', async () => {
- const pick = vi.fn(async () => null as string | null)
- const props = owner()
- const view = render(<NativeDirectoryFlow {...props} pick={pick} />)
- await act(async () => {})
- expect(props.onCancel).toHaveBeenCalledOnce()
- expect(props.onPicked).not.toHaveBeenCalled()
- // Withdraw and reopen: a fresh request runs a fresh pick.
- view.rerender(<NativeDirectoryFlow {...props} open={false} pick={pick} />)
- view.rerender(<NativeDirectoryFlow {...props} pick={pick} />)
- await act(async () => {})
- expect(pick).toHaveBeenCalledTimes(2)
- })
- it('folds pick failures into onError messages', async () => {
- const props = owner()
- render(<NativeDirectoryFlow {...props} pick={vi.fn(async () => { throw new Error('no chooser installed') })} />)
- await act(async () => {})
- expect(props.onError).toHaveBeenCalledWith('no chooser installed')
- const nonError = owner()
- render(<NativeDirectoryFlow {...nonError} pick={vi.fn(async () => { throw 'denied' })} />)
- await act(async () => {})
- expect(nonError.onError).toHaveBeenCalledWith('denied')
- })
- it('renders nothing while closed and while open', () => {
- const closed = render(<NativeDirectoryFlow {...owner({ open: false })} pick={vi.fn(async () => null)} />)
- expect(closed.container.innerHTML).toBe('')
- const opened = render(<NativeDirectoryFlow {...owner()} pick={vi.fn(async () => null)} />)
- expect(opened.container.innerHTML).toBe('')
- })
- })
|