// @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 => '/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 { 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('fails loudly instead of deduplicating a duplicate package row', async () => { const b = await bench() b.declare() await b.ctx.plugin({ inject: [...inject], apply }).await() const duplicate = b.ctx.plugin({ inject: [...inject], apply }) await expect(duplicate.await()).rejects.toThrow(/already has a registration/) for (const hole of HOLES) expect(b.slots.entries(hole)).toHaveLength(1) }) it('rolls back wholesale and reports loudly when a rival injection wins declaration 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 { // The rival subscribes first, so synchronous declaration notifications // let it occupy the pair before this provider's waiting injection runs. b.slots.inject(HOLES[0], () => b.slots.inject(HOLES[1], function* () { yield b.slots.register({ name: HOLES[0] } as never, () => null) yield b.slots.register({ name: HOLES[1] } as never, () => null) })) await b.ctx.plugin({ inject: [...inject], apply }).await() b.declare() 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 outer injection 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 outer injection 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 })() 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((settle) => { resolve = settle })) const first = owner() const view = render() expect(pick).toHaveBeenCalledOnce() // Re-renders while open (busy flips, handler identity changes) must not relaunch the chooser. const second = owner() view.rerender() 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(() => {})) view.rerender() 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((settle) => { resolve = settle })) const props = owner() const view = render() 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((_settle, rejectPick) => { reject = rejectPick })) const late = owner() const failingView = render() 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() await act(async () => {}) expect(props.onCancel).toHaveBeenCalledOnce() expect(props.onPicked).not.toHaveBeenCalled() // Withdraw and reopen: a fresh request runs a fresh pick. view.rerender() view.rerender() await act(async () => {}) expect(pick).toHaveBeenCalledTimes(2) }) it('folds pick failures into onError messages', async () => { const props = owner() render( { throw new Error('no chooser installed') })} />) await act(async () => {}) expect(props.onError).toHaveBeenCalledWith('no chooser installed') const nonError = owner() render( { throw 'denied' })} />) await act(async () => {}) expect(nonError.onError).toHaveBeenCalledWith('denied') }) it('renders nothing while closed and while open', () => { const closed = render( null)} />) expect(closed.container.innerHTML).toBe('') const opened = render( null)} />) expect(opened.container.innerHTML).toBe('') }) })