diff --git a/packages/client/ui-conversation/tests/apply-inject.spec.tsx b/packages/client/ui-conversation/tests/apply-inject.spec.tsx index 3320c79d3a..416f3fa4ef 100644 --- a/packages/client/ui-conversation/tests/apply-inject.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-inject.spec.tsx @@ -1,144 +1,73 @@ // @vitest-environment jsdom // apply inject factories exercised end to end against the terminal thin -// shape: the conversation surface (views triple, send choreography incl. -// optimistic clear + failure restore THROUGH the declared store actions, -// openDetails = select action + layout orchestration, sessions.open -// navigation), and the closeDetails details surface. Complements -// chat-apply.spec.tsx (registration) -// and selection-survival.spec.ts (store axis). History opening is NOT an -// inject concern anymore — the runtime sessions service opens on watch -// (sessions-service.spec.ts owns that behavior). +// shape: the strict session surface (views triple, draft mirror), the +// provide-channel input face (machine-sink submit choreography incl. +// optimistic clear + failure restore), the resident surface (selectWorkspace +// draft carrying), the composer-bar stop face, openDetails = select action + +// layout orchestration, and the closeDetails details surface. Complements +// chat-apply.spec.tsx (registration) and selection-survival.spec.tsx (store +// axis). History opening is NOT an inject concern — the runtime sessions +// service opens on watch (sessions-service.spec.ts owns that behavior). +// +// The inject surfaces are read off the ledger entries deliberately (typed at +// this spec's own contract): these cases pin factory choreography the UI +// guards would mask. Rendering-path acceptance lives in +// chat-toolview-slot.spec.tsx. -import { Context } from 'cordis' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { cleanup } from '@testing-library/react' -import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' -import { SlotsService, scopeOf } from '@deepseek-ai/dsh-client-runtime/client' -import type { - SessionId, SessionListState, WorkspaceListState, -} from '@deepseek-ai/dsh-client-runtime/client' -import type { SlotRendererHost } from '@deepseek-ai/dsh-client-web-react' +import { describe, expect, it, vi } from 'vitest' +import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' +import type { SessionBehaviorOverrides } from '@deepseek-ai/dsh-client-test-runtime' +import type { ISession, SessionId } from '@deepseek-ai/dsh-client-runtime/client' import { apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { ChatViewInjected, ComposerBarInjected, ConversationInjected, ConversationSessionInjected, DetailsInjected, } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { createChatStore } from '../src/client/stores.ts' -afterEach(cleanup) - const ROOT = 'root-1' as SessionId type ChatInstance = ReturnType['create']> type ChatActions = ChatInstance['actions'] -const SCOPE_TAG: symbol = (() => { - const recorded: (string | symbol)[] = [] - const spy = new Proxy(new Context(), { - get(target, prop, receiver) { - recorded.push(prop) - // Reflect.get is typed any; the probe only records property names. - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - return Reflect.get(target, prop, receiver) - }, - }) - void scopeOf(spy) - const symbol = recorded.find((p): p is symbol => typeof p === 'symbol') - if (symbol === undefined) throw new Error('scopeOf probe recorded no symbol read') - return symbol -})() +/** ISession verb mocks, typed against the production face (['prompt'] etc. keep vitest mock ergonomics). */ +function sessionFakeFor() { + return { + open: vi.fn(() => Promise.resolve()), + loadOlder: vi.fn(() => Promise.resolve()), + prompt: vi.fn(() => Promise.resolve({ ok: true, value: { accepted: true } })), + cancel: vi.fn(() => Promise.resolve({ ok: true, value: { accepted: true } })), + } satisfies SessionBehaviorOverrides +} async function bench() { - const ctx = new Context() - const slotsFiber = ctx.plugin(SlotsService) - await slotsFiber.await() - const slots = ctx.get('slots') as SlotsService - - const listStore = createSnapshotStore({ - ids: [ROOT], - byId: { [ROOT]: { id: ROOT, title: 'R', displayTitle: 'R', cwd: '/proj', running: false, blank: false, updatedAt: 1 } }, - current: ROOT, - phase: 'ready', + const runtime = await SlotTestRuntime.create() + const sessionFake = sessionFakeFor() + await runtime.sessions.add({ + id: ROOT, + summary: { title: 'R', displayTitle: 'R', cwd: '/proj' }, + session: sessionFake, }) - const sessionFake = { - sessionId: ROOT, - open: vi.fn(() => Promise.resolve()), - loadOlder: vi.fn(() => Promise.resolve()), - prompt: vi.fn<() => Promise<{ ok: boolean; value?: object; error?: { code: string; message: string } }>>( - () => Promise.resolve({ ok: true, value: { accepted: true } })), - cancel: vi.fn<() => Promise<{ ok: boolean; value?: object; error?: { code: string; message: string } }>>( - () => Promise.resolve({ ok: true, value: { accepted: true } })), - // Observable face (the input machine's queue read face rides it). - getSnapshot: () => ({ queue: [] }), - subscribe: () => () => {}, - } - const scopes = new Map() - const mint = (id: SessionId): Context => { - let scoped = scopes.get(id) - if (scoped === undefined) { - scoped = ctx.plugin(() => {}).ctx.extend({ [SCOPE_TAG]: id }) - scopes.set(id, scoped) - } - return scoped - } - type TestProvider = { - resolve(binding: { sessionId: SessionId; session: typeof sessionFake; ctx: Context }): { - hooks?: Record - props?: Record - } - } - const providers: TestProvider[] = [] - const absentInfo = { sessionId: undefined, hooks: {}, props: {} } - const sessionsFake = { - list: listStore, - binding: (id: SessionId) => ({ sessionId: id, session: sessionFake, ctx: mint(id) }), - scope: (id: SessionId) => mint(id), - provideInfo: () => undefined, - currentProvideInfo: { getSnapshot: () => absentInfo, subscribe: () => () => {} }, - provide: (descriptor: TestProvider) => { providers.push(descriptor); return () => {} }, - scopeOf, - sessionOf: (actx: Context) => (scopeOf(actx) === undefined ? undefined : sessionFake), - open: vi.fn(), - } - ctx.provide('sessions', sessionsFake) - const workspaceStore = createSnapshotStore({ - items: [], state: 'idle', phase: 'ready', error: null, - baselinesReady: true, recentWorkspaceId: undefined, - }) - const workspacesFake = { - list: workspaceStore, - connectWorkspace: vi.fn(async () => ROOT), - openPath: vi.fn(async () => {}), - } - ctx.provide('workspaces', workspacesFake) const layoutFake = { openDetails: vi.fn(), closeDetails: vi.fn() } - ctx.provide('layout', layoutFake) - ctx.provide('locale', { bind: () => (key: string) => key }) + runtime.provide('layout', layoutFake) - // The AppFrame role: the three conversation-package slots must be declared - // by a live entry before apply can contribute into them (the stand-in - // consumes renderSlot to satisfy the declare-means-render check). - slots.register({ - name: 'root', - children: { - 'conversation': { kind: 'single', scope: 'session-maybe' }, - 'details': { kind: 'single', scope: 'session' }, - }, + // The AppFrame role: the conversation-package slots must be declared by a + // live entry before apply can contribute into them. + await runtime.root.declare({ + 'conversation': { kind: 'single', scope: 'session-maybe' }, + 'details': { kind: 'single', scope: 'session' }, }, (_p: { renderSlot?: unknown }) => null) - const fiber = ctx.plugin({ inject: [...inject], apply }) - await fiber.await() + const feature = await runtime.mount({ inject: [...inject], apply }) - // Reach the render-side entry view (inject + store handle) the way the - // renderer does: through the host face. - let host: SlotRendererHost | undefined - slots.install({ renderRoot: (h) => { host = h; return null } }) - slots.renderSlot('root', {}) - const hostFace = host! - const entryOf = (key: 'conversation' | 'conversation.session' | 'conversation.composer.bar' | 'conversation.view' | 'details') => hostFace.entriesOf(key)[0]! + // The host face (store resolution) exists only inside the installed + // renderer, so materialize it the way the shell does. + runtime.renderRoot() + const entryOf = (key: 'conversation' | 'conversation.session' | 'conversation.composer.bar' | 'conversation.view' | 'details') => + runtime.slots.entries(key)[0]! /** Resolve store instance + call the inject the way the outlet would. */ const conversationSurface = (id: SessionId) => { const entry = entryOf('conversation.session') - const instance = hostFace.storeOf(entry, id) as ChatInstance + const instance = runtime.storeOf('conversation.session', id) as ChatInstance const injected = (entry.inject as unknown as (sessionId: SessionId, actions: ChatActions) => ConversationSessionInjected)( id, instance.actions) return { instance, injected } @@ -154,27 +83,28 @@ async function bench() { /** Same resolution for the chat entry riding the view ring. */ const chatViewSurface = (id: SessionId) => { const entry = entryOf('conversation.view') - const instance = hostFace.storeOf(entry, id) as ChatInstance + const instance = runtime.storeOf('conversation.view', id) as ChatInstance const injected = (entry.inject as unknown as (sessionId: SessionId, actions: ChatActions) => ChatViewInjected)( id, instance.actions) return { instance, injected } } /** Materialize the input provide contribution the way the runtime does. */ const inputSurface = (id: SessionId) => { - const contribution = providers[0]!.resolve(sessionsFake.binding(id)) - const state = contribution.hooks!['input'] as { + const info = runtime.sessions.provideInfo(id)! + const state = info.hooks['input'] as { getSnapshot: () => { draft: string } subscribe: (fn: () => void) => () => void } - const actions = contribution.props!['inputActions'] as { + const actions = info.props['inputActions'] as { setDraft: (text: string) => void submit: (mode?: 'queue' | 'steer') => void } return { state, actions } } return { - ctx, slots, hostFace, entryOf, conversationSurface, residentSurface, composerSurface, chatViewSurface, inputSurface, - sessionFake, sessionsFake, workspacesFake, layoutFake, mint, + runtime, feature, slots: runtime.slots, entryOf, + conversationSurface, residentSurface, composerSurface, chatViewSurface, inputSurface, + sessionFake, layoutFake, } } @@ -190,6 +120,7 @@ describe('conversation slot inject surface', () => { const chatView = b.chatViewSurface(ROOT) chatView.injected.loadOlder() expect(b.sessionFake.loadOlder).toHaveBeenCalledTimes(1) + await b.runtime.dispose() }) it('the provide-channel input face submits through the machine sink: trim, optimistic clear, failure restore without clobber', async () => { @@ -208,14 +139,14 @@ describe('conversation slot inject surface', () => { await Promise.resolve() expect(b.sessionFake.prompt).toHaveBeenCalledWith([{ type: 'text', text: 'hello' }], 'queue') // Failure: restored (draft still empty when the rejection lands). - b.sessionFake.prompt.mockResolvedValueOnce({ ok: false, error: { code: 'agent-busy', message: 'b' } }) + b.sessionFake.prompt.mockResolvedValueOnce({ ok: false, error: { code: 'agent-busy', message: 'b', details: { reason: 'b' } } }) actions.setDraft('retry me') actions.submit('queue') await vi.waitFor(() => { expect(state.getSnapshot().draft).toBe('retry me') }) // Failure landing after new typing: no clobber (restore fills empty only). - b.sessionFake.prompt.mockResolvedValueOnce({ ok: false, error: { code: 'agent-busy', message: 'b' } }) + b.sessionFake.prompt.mockResolvedValueOnce({ ok: false, error: { code: 'agent-busy', message: 'b', details: { reason: 'b' } } }) actions.submit('queue') actions.setDraft('typed during flight') await new Promise(r => setTimeout(r, 0)) @@ -229,23 +160,25 @@ describe('conversation slot inject surface', () => { expect(mirrored).toEqual(['mirrored text']) unbind() // Stop failure is swallowed (promptError owns the surface). - b.sessionFake.cancel.mockResolvedValueOnce({ ok: false, error: { code: 'internal', message: 'x' } }) + b.sessionFake.cancel.mockResolvedValueOnce({ ok: false, error: { code: 'internal', message: 'x', details: {} } }) b.composerSurface(ROOT).stop() await new Promise(r => setTimeout(r, 0)) expect(b.sessionFake.cancel).toHaveBeenCalledTimes(1) + await b.runtime.dispose() }) - it('inject fails loud when the session resolves no scope or the scope lacks the service', async () => { + it('inject fails loud when the session resolves no binding or the scope lacks the service', async () => { const b = await bench() const entry = b.entryOf('conversation.composer.bar') const injectFn = entry.inject as unknown as (sessionId: SessionId) => ComposerBarInjected - // Unknown session: sessions.scope answers nothing. - ;(b.sessionsFake.scope as unknown) = () => undefined - expect(() => { injectFn(ROOT).stop() }).toThrow(/resolved no scope/) - // A scope minted outside the service tree: no conversation service on it. - const foreign = new Context() - ;(b.sessionsFake.scope as unknown) = () => foreign.plugin(() => {}).ctx.extend({}) - expect(() => { injectFn(ROOT).stop() }).toThrow(/unavailable through the session scope/) + // Unknown session: the keyboard face's binding resolution answers nothing. + expect(() => { injectFn('ghost' as SessionId).stop() }).toThrow(/resolved no binding/) + // A scope whose service tree lost 'conversation' (the feature fiber + // unloaded while a retained inject closure re-runs): fails loud too. + const stop = injectFn(ROOT).stop + await b.feature.dispose() + expect(() => { stop() }).toThrow(/unavailable through the session scope/) + await b.runtime.dispose() }) it('openDetails (chat view face) writes the selection through the store actions and opens the panel', async () => { @@ -258,6 +191,7 @@ describe('conversation slot inject surface', () => { // writes land where the skeleton and details read. const conv = b.conversationSurface(ROOT) expect(conv.instance).toBe(instance) + await b.runtime.dispose() }) it('openFile (chat view face) resolves against session cwd and calls workspaces.openPath', async () => { @@ -265,8 +199,9 @@ describe('conversation slot inject surface', () => { const { injected } = b.chatViewSurface(ROOT) injected.openFile('src/a.ts') await vi.waitFor(() => { - expect(b.workspacesFake.openPath).toHaveBeenCalledWith('/proj/src/a.ts') + expect(b.runtime.workspaces.calls).toContainEqual({ method: 'openPath', args: ['/proj/src/a.ts'] }) }) + await b.runtime.dispose() }) it('routes navigation and workspace switching through the runtime owners, carrying the draft', async () => { @@ -274,23 +209,73 @@ describe('conversation slot inject surface', () => { const { injected } = b.conversationSurface(ROOT) const resident = b.residentSurface(ROOT) injected.open(ROOT) - expect(b.sessionsFake.open).toHaveBeenCalledWith(ROOT) + expect(b.runtime.sessions.calls).toContainEqual({ method: 'open', args: [ROOT] }) // Same-session connect (the picked workspace resolves to this session): // no draft movement, plain re-open. + b.runtime.workspaces.stub('connectWorkspace', () => Promise.resolve(ROOT)) const { state, actions } = b.inputSurface(ROOT) actions.setDraft('carry me') void resident.selectWorkspace('workspace-1' as never) - await vi.waitFor(() => { expect(b.sessionsFake.open).toHaveBeenCalledTimes(2) }) - expect(b.workspacesFake.connectWorkspace).toHaveBeenCalledWith('workspace-1') + await vi.waitFor(() => { + expect(b.runtime.sessions.calls.filter(c => c.method === 'open')).toHaveLength(2) + }) + expect(b.runtime.workspaces.calls).toContainEqual({ method: 'connectWorkspace', args: ['workspace-1'] }) expect(state.getSnapshot().draft).toBe('carry me') // Cross-session connect: the draft MOVES — the old machine empties, the // new session's machine receives the text, then navigation lands there. const OTHER = 'other-1' as SessionId - b.workspacesFake.connectWorkspace.mockResolvedValueOnce(OTHER) + await b.runtime.sessions.add({ id: OTHER }, { current: false }) + b.runtime.workspaces.stub('connectWorkspace', () => Promise.resolve(OTHER)) void resident.selectWorkspace('workspace-2' as never) - await vi.waitFor(() => { expect(b.sessionsFake.open).toHaveBeenCalledWith(OTHER) }) + await vi.waitFor(() => { + expect(b.runtime.sessions.calls).toContainEqual({ method: 'open', args: [OTHER] }) + }) expect(state.getSnapshot().draft).toBe('') expect(b.inputSurface(OTHER).state.getSnapshot().draft).toBe('carry me') + await b.runtime.dispose() + }) + + it('selectWorkspace edge arms: no-session resident, empty-draft move, connect failure retryable', async () => { + const b = await bench() + // No-session resident (hero before any session): connect resolves and + // navigation proceeds without any draft choreography. + const noSession = b.residentSurface(undefined) + b.runtime.workspaces.stub('connectWorkspace', () => Promise.resolve(ROOT)) + void noSession.selectWorkspace('workspace-0' as never) + await vi.waitFor(() => { + expect(b.runtime.sessions.calls).toContainEqual({ method: 'open', args: [ROOT] }) + }) + + // Cross-session connect with an EMPTY draft: no move, no clearing. + const OTHER = 'b9-other' as SessionId + await b.runtime.sessions.add({ id: OTHER }, { current: false }) + const resident = b.residentSurface(ROOT) + const { state } = b.inputSurface(ROOT) + expect(state.getSnapshot().draft).toBe('') + b.runtime.workspaces.stub('connectWorkspace', () => Promise.resolve(OTHER)) + void resident.selectWorkspace('workspace-3' as never) + await vi.waitFor(() => { + expect(b.runtime.sessions.calls).toContainEqual({ method: 'open', args: [OTHER] }) + }) + expect(b.inputSurface(OTHER).state.getSnapshot().draft).toBe('') + + // Connect failure: the rejection propagates to the caller (the view owns + // the rollback) and no further navigation happens. + const opens = b.runtime.sessions.calls.filter(c => c.method === 'open').length + b.runtime.workspaces.stub('connectWorkspace', () => Promise.reject(new Error('offline'))) + await expect(resident.selectWorkspace('workspace-4' as never)).rejects.toThrow('offline') + expect(b.runtime.sessions.calls.filter(c => c.method === 'open')).toHaveLength(opens) + await b.runtime.dispose() + }) + + it('scopedConversation fails loud when the session resolves no scope', async () => { + const b = await bench() + // The chat-view inject resolves the scoped conversation service at inject + // time: an unlisted session hits the scope() === undefined throw directly. + const entry = b.entryOf('conversation.view') + const injectFn = entry.inject as unknown as (sessionId: SessionId, actions: unknown) => unknown + expect(() => injectFn('never-listed' as SessionId, {})).toThrow(/resolved no scope/) + await b.runtime.dispose() }) it('views read face projects the ring ledger (subscribe/version through ctx.slots)', async () => { @@ -313,6 +298,7 @@ describe('conversation slot inject surface', () => { off() off2() unsub() + await b.runtime.dispose() }) }) @@ -325,9 +311,9 @@ describe('details inject surface', () => { injected.closeDetails() expect(b.layoutFake.closeDetails).toHaveBeenCalledTimes(1) // The shared handle: details resolves the SAME instance conversation writes. - const conv = b.hostFace.storeOf(b.entryOf('conversation.session'), ROOT) - const details = b.hostFace.storeOf(entry, ROOT) + const conv = b.runtime.storeOf('conversation.session', ROOT) + const details = b.runtime.storeOf('details', ROOT) expect(details).toBe(conv) + await b.runtime.dispose() }) - }) diff --git a/packages/client/ui-conversation/tests/chat-apply.spec.tsx b/packages/client/ui-conversation/tests/chat-apply.spec.tsx index afafafa3dc..4d6dc99f4a 100644 --- a/packages/client/ui-conversation/tests/chat-apply.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-apply.spec.tsx @@ -1,89 +1,53 @@ // @vitest-environment jsdom // apply wiring: the conversation service provided, the chat view registered // as the first 'conversation.view' ring entry declaring the keyed toolview -// hole, the three slot registrations land against a root entry's children -// declarations (the AppFrame role), the shared store handle rides all session -// entries, and the bash sample mounts through the load-order seam as a keyed -// entry. Full-chain rendering belongs to the machinery spec -// (chat-toolview-slot.spec.tsx) and the shell e2e; this spec stops at the -// assembly surface. +// hole, the slot registrations land against a root entry's children +// declarations (the AppFrame role), the shared store handle rides all strict +// session entries, and the bash sample + todo row mount through the +// load-order seam as keyed entries. Full-chain rendering belongs to the +// machinery spec (chat-toolview-slot.spec.tsx) and the shell e2e; this spec +// stops at the assembly surface. -import { Context } from 'cordis' import { describe, expect, it, vi } from 'vitest' -import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' -import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' -import type { SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client' +import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' +import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' import { apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client' const ROOT = 'root-1' as SessionId const CHILD = 'child-1' as SessionId async function bench() { - const ctx = new Context() - const slotsFiber = ctx.plugin(SlotsService) - await slotsFiber.await() + const runtime = await SlotTestRuntime.create() + await runtime.sessions.add({ id: ROOT, summary: { title: 'R', displayTitle: 'R' } }, { current: false }) + await runtime.sessions.add( + { id: CHILD, summary: { title: 'C', displayTitle: 'C', parentId: ROOT } }, { current: false }) + runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() }) - const listStore = createSnapshotStore({ - ids: [ROOT, CHILD], - byId: { - [ROOT]: { id: ROOT, title: 'R', displayTitle: 'R', running: false, blank: false, updatedAt: 1 }, - [CHILD]: { id: CHILD, title: 'C', displayTitle: 'C', parentId: ROOT, running: false, blank: false, updatedAt: 2 }, - }, - current: undefined, - phase: 'ready', - }) - const absentInfo = { sessionId: undefined, hooks: {}, props: {} } - const sessionsFake = { - list: listStore, - binding: vi.fn(), - scope: () => undefined, - provideInfo: () => undefined, - currentProvideInfo: { getSnapshot: () => absentInfo, subscribe: () => () => {} }, - provide: vi.fn(() => () => {}), - create: vi.fn(), - open: vi.fn(), - updateIntent: vi.fn(), - } - ctx.provide('sessions', sessionsFake) - ctx.provide('workspaces', { - startSession: vi.fn(), - sendSession: vi.fn(), - openPath: vi.fn(async () => {}), - }) - ctx.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() }) - ctx.provide('locale', { bind: () => (key: string) => key }) - - // Declared by ui-layout's root entry in production; a stand-in root - // occupant declares them here so the contributions land (it consumes - // renderSlot to satisfy the declare-means-render check). - const slots = ctx.get('slots') as SlotsService - slots.register({ - name: 'root', - children: { - 'conversation': { kind: 'single', scope: 'session-maybe' }, - 'details': { kind: 'single', scope: 'session' }, - }, + // Declared by ui-layout's root entry in production; the test root declares + // them here so the contributions land. + await runtime.root.declare({ + 'conversation': { kind: 'single', scope: 'session-maybe' }, + 'details': { kind: 'single', scope: 'session' }, }, (_p: { renderSlot?: unknown }) => null) - const fiber = ctx.plugin({ inject: [...inject], apply }) - return { ctx, fiber, slots } + const feature = await runtime.mount({ inject: [...inject], apply }) + return { runtime, feature, slots: runtime.slots } } /** First stored entry for a key (inject/store live directly on StoredEntry). */ -function renderEntryOf(slots: SlotsService, key: 'conversation' | 'conversation.session' | 'conversation.view' | 'details') { +function renderEntryOf(slots: Awaited>['slots'], key: 'conversation' | 'conversation.session' | 'conversation.view' | 'details') { return slots.entries(key)[0] as undefined | { inject?: unknown; store?: unknown } } describe('apply wiring', () => { it('provides the conversation service', async () => { const b = await bench() - await b.fiber.await() - expect(b.ctx.get('conversation')).toBeDefined() + expect(b.runtime.ctx.get('conversation')).toBeDefined() + await b.runtime.dispose() }) it('registers the chat view as the first ring entry, declaring the keyed toolview hole', async () => { const b = await bench() - await b.fiber.await() const entries = b.slots.entries('conversation.view') expect(entries.map(e => e.options.id)).toEqual(['chat']) expect(entries[0]?.options.label).toBe('Chat') @@ -91,11 +55,11 @@ describe('apply wiring', () => { // Declaring is claiming: the chat entry's registration put the hole on // the ledger with the contract's kind/scope. expect(b.slots.spec('conversation.chat.toolview')).toEqual({ kind: 'keyed', scope: 'session' }) + await b.runtime.dispose() }) it('occupies the slots + the ring; session entries share one store handle', async () => { const b = await bench() - await b.fiber.await() const conversation = renderEntryOf(b.slots, 'conversation') const conversationSession = renderEntryOf(b.slots, 'conversation.session') const chatView = renderEntryOf(b.slots, 'conversation.view') @@ -111,21 +75,21 @@ describe('apply wiring', () => { // The hero workspace picker hole rides the conversation entry's children // declaration (the empty-state occupant is gone). expect(b.slots.spec('conversation.hero.workspace')).toEqual({ kind: 'single', scope: 'root' }) + await b.runtime.dispose() }) it('mounts the bash sample and the todo row as keyed entries through the load-order seam', async () => { const b = await bench() - await b.fiber.await() // Both registrant plugins' inject: ['slots', 'conversation'] resolved — the // service being present implies the chat entry declared the hole first. const entries = b.slots.entries('conversation.chat.toolview') expect(entries.map(e => e.options.key)).toEqual(['bash', 'todo_write']) + await b.runtime.dispose() }) it('plugin fiber disposal collects every registration (unload cascade, ring and hole included)', async () => { const b = await bench() - await b.fiber.await() - await b.fiber.dispose() + await b.feature.dispose() expect(b.slots.entries('conversation')).toHaveLength(0) // The declared ring collapses with its declaring entry, and the chat // entry's keyed hole (with the sample's registration) collapses with it. @@ -133,6 +97,7 @@ describe('apply wiring', () => { expect(b.slots.entries('conversation.chat.toolview')).toHaveLength(0) expect(b.slots.spec('conversation.chat.toolview')).toBeUndefined() expect(b.slots.entries('details')).toHaveLength(0) - expect(b.ctx.get('conversation')).toBeUndefined() + expect(b.runtime.ctx.get('conversation')).toBeUndefined() + await b.runtime.dispose() }) }) diff --git a/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx b/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx index 784c04c571..3d2e9ea0e6 100644 --- a/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom // The dissolved tool ring's acceptance chain on the REAL machinery stack: -// cordis Context + SlotsService ledger + the web-react renderer + this -// package's own apply — no outlet twins. Proves the keyed +// SlotTestRuntime (cordis Context + SlotsService ledger + the web-react +// renderer) + this package's own apply — no outlet twins. Proves the keyed // 'conversation.chat.toolview' hole end to end: registered rows dispatch by // entryKey (the bash sample lands through its plugin), unregistered tools // fall back to GenericToolCard at the render site, live registration/unload @@ -10,23 +10,16 @@ // inject: ['slots', 'conversation'] load-order seam suspends on real fiber // semantics until the service (and with it the hole declaration) is present. -import { Context } from 'cordis' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { act, cleanup, fireEvent, render } from '@testing-library/react' -import { createSnapshotStore, SlotsService } from '@deepseek-ai/dsh-client-runtime/client' -import type { - ConversationSnapshot, SessionId, SessionListState, ToolResultNode, WorkspaceListState, -} from '@deepseek-ai/dsh-client-runtime/client' -import { createSlotRenderer } from '@deepseek-ai/dsh-client-web-react' +import { cleanup, fireEvent } from '@testing-library/react' +import type { ISession, SessionId, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client' import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' +import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' import { apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client' const SID = 's1' as SessionId -/** Identity-stable no-session bundle (uSES getSnapshot contract). */ -const ABSENT_INFO = { sessionId: undefined, hooks: {}, props: {} } - afterEach(cleanup) // The chat store persists under its declared key; clear between cases. beforeEach(() => { @@ -40,117 +33,38 @@ const toolResult = (seq: number, callId: string, name: string, args = '{"command content: [], isError: false, callView: null, resultView: null, }) -function snapshotWith(nodes: ToolResultNode[]): ConversationSnapshot { - return { - sessionId: SID, nodes, foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(), - pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, - hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null, - } -} - /** Test-owned AppFrame role: declares and renders the resident conversation area. */ type AppRootProps = PropsRenderSlots<'conversation' | 'details'> function AppRoot({ renderSlot }: AppRootProps) { return <>{renderSlot('conversation', {})} } +const LAYOUT_CHILDREN = { + 'conversation': { kind: 'single', scope: 'session-maybe' }, + 'details': { kind: 'single', scope: 'session' }, +} as const + /** - * Real-stack bench: SlotsService plugin, renderer installed, sessions/layout - * fakes at the service seams only (external boundaries), the package apply on - * its own fiber, and the test AppFrame occupying 'root'. + * Real-stack bench: SlotTestRuntime with the session/layout doubles at the + * service seams only (external boundaries), the package apply on its own + * fiber, and the test AppFrame occupying 'root'. */ async function bench(nodes: ToolResultNode[]) { - const ctx = new Context() - const slotsFiber = ctx.plugin(SlotsService) - await slotsFiber.await() - const slots = ctx.get('slots') as SlotsService - - const session = createSnapshotStore(snapshotWith(nodes)) - const list = createSnapshotStore({ - ids: [SID], - byId: { [SID]: { id: SID, title: 'S', displayTitle: 'S', running: false, blank: false, updatedAt: 1 } }, - current: SID, - phase: 'ready', - }) - // Identity-stable provide bundle: the renderer caches hooks per source and - // inject results per bundle, both by object identity. Registered providers - // (the package's input contribution) materialize into it lazily, once. - const providers: ((binding: object) => { hooks?: object; props?: object })[] = [] - let info: { sessionId: SessionId; hooks: object; props: object } | undefined - const scoped = { send: vi.fn(async () => {}), cancel: vi.fn(async () => {}) } + const runtime = await SlotTestRuntime.create() const layout = { openDetails: vi.fn(), closeDetails: vi.fn() } - const actxFake = { get: () => scoped, effect: () => {}, on: () => () => {} } - const bindingOf = (id: SessionId) => ({ - sessionId: id, - ctx: actxFake, + runtime.provide('layout', layout) + await runtime.sessions.add({ + id: SID, + summary: { title: 'S', displayTitle: 'S' }, + snapshot: { nodes }, session: { - sessionId: id, - loadOlder: vi.fn(), - prompt: vi.fn(async () => ({ ok: true, value: { accepted: true } })), - // Observable face for the input machine's queue read face. - getSnapshot: () => session.getSnapshot(), - subscribe: (fn: () => void) => session.subscribe(fn), + loadOlder: vi.fn(), + prompt: vi.fn(async () => ({ ok: true, value: { accepted: true } })), }, }) - const provideInfo = (id: string) => { - if (id !== SID) return undefined - if (info === undefined) { - const hooks: Record = { session } - const props: Record = {} - for (const provider of providers) { - const c = provider(bindingOf(SID)) - Object.assign(hooks, c.hooks ?? {}) - Object.assign(props, c.props ?? {}) - } - info = { sessionId: SID, hooks, props } - } - return info - } - ctx.provide('sessions', { - list, - binding: bindingOf, - scope: () => actxFake, - provideInfo, - currentProvideInfo: { - getSnapshot: () => provideInfo(SID), - subscribe: () => () => {}, - }, - provide: (d: { resolve: (typeof providers)[number] }) => { providers.push(d.resolve); return () => {} }, - scopeOf: () => SID, - create: vi.fn(), - open: vi.fn(), - updateIntent: vi.fn(), - }) - const workspaces = { - list: createSnapshotStore({ - items: [], state: 'idle', phase: 'ready', error: null, - baselinesReady: true, recentWorkspaceId: undefined, - }), - startSession: vi.fn(), - sendSession: vi.fn(), - openPath: vi.fn(async () => {}), - } - ctx.provide('workspaces', workspaces) - ctx.provide('layout', layout) - ctx.provide('locale', { bind: () => (key: string) => key }) - - slots.install(createSlotRenderer()) - slots.register({ - name: 'root', - children: { - 'conversation': { kind: 'single', scope: 'session-maybe' }, - 'details': { kind: 'single', scope: 'session' }, - }, - }, AppRoot) - - const fiber = ctx.plugin({ inject: [...inject], apply }) - await fiber.await() - return { ctx, slots, fiber, session, list, layout, workspaces } -} - -/** Render the whole tree through the ctx-level root seam (the shell's own entry). */ -function mountApp(slots: SlotsService) { - return render(<>{slots.renderSlot('root', {})}) + await runtime.root.declare(LAYOUT_CHILDREN, AppRoot) + const feature = await runtime.mount({ inject: [...inject], apply }) + return { runtime, slots: runtime.slots, feature, layout } } describe('keyed toolview hole through the real machinery', () => { @@ -159,7 +73,7 @@ describe('keyed toolview hole through the real machinery', () => { toolResult(3, 'c1', 'bash'), toolResult(4, 'c2', 'mystery', '{"n":1}'), ]) - const view = mountApp(b.slots) + const view = b.runtime.renderRoot() // bash: the sample plugin's keyed registration took the row (root // session → global arm, decided inside the component off useSessions). expect(view.container.querySelector('[data-sample="bash-global"]')).not.toBeNull() @@ -167,6 +81,7 @@ describe('keyed toolview hole through the real machinery', () => { expect(view.getByText('Build')).toBeTruthy() // mystery: no registration under that key → render-site fallback. expect(view.getByText('Tool call')).toBeTruthy() + await b.runtime.dispose() }) it('renders top-level Cordis calls with lifecycle titles over the generic variants', async () => { @@ -176,7 +91,7 @@ describe('keyed toolview hole through the real machinery', () => { toolResult(4, 'cordis-2', 'cordis_mount', JSON.stringify({ code })), toolResult(5, 'cordis-3', 'cordis_unmount', '{"id":"dyn-2"}'), ]) - const view = mountApp(b.slots) + const view = b.runtime.renderRoot() expect(view.container.querySelector('[data-tool="cordis_inspect"]')?.textContent).toContain('Inspect') const mounted = view.container.querySelector('[data-variant="code"]') @@ -186,42 +101,46 @@ describe('keyed toolview hole through the real machinery', () => { fireEvent.click(mounted!.querySelector('button[aria-expanded]')!) expect(mounted!.querySelector('pre.shiki')?.textContent).toBe(code) + await b.runtime.dispose() }) it('file-path clicks travel owner openFile → chat inject → workspaces.openPath', async () => { const b = await bench([toolResult(3, 'c1', 'read', '{"path":"src/a.ts"}')]) - const view = mountApp(b.slots) + const view = b.runtime.renderRoot() view.getByText('src/a.ts').click() expect(b.layout.openDetails).not.toHaveBeenCalled() await vi.waitFor(() => { - expect(b.workspaces.openPath).toHaveBeenCalledWith('src/a.ts') + expect(b.runtime.workspaces.calls).toContainEqual({ method: 'openPath', args: ['src/a.ts'] }) }) + await b.runtime.dispose() }) it('bash summary clicks do not open details or host paths', async () => { const b = await bench([toolResult(3, 'c1', 'bash')]) - const view = mountApp(b.slots) + const view = b.runtime.renderRoot() view.getByText('Build').click() expect(b.layout.openDetails).not.toHaveBeenCalled() - expect(b.workspaces.openPath).not.toHaveBeenCalled() + expect(b.runtime.workspaces.calls.some(c => c.method === 'openPath')).toBe(false) + await b.runtime.dispose() }) it('a live keyed registration takes over its tool row and unload reverts to the fallback', async () => { const b = await bench([toolResult(3, 'c2', 'mystery', '{"n":1}')]) - const view = mountApp(b.slots) + const view = b.runtime.renderRoot() expect(view.getByText('Tool call')).toBeTruthy() let dispose = (): void => {} - await act(async () => { - dispose = b.slots.register( - { name: 'conversation.chat.toolview', key: 'mystery' }, - () =>
) - }) + dispose = b.slots.register( + { name: 'conversation.chat.toolview', key: 'mystery' }, + () =>
) + await b.runtime.flush() // Per-key version tick: the row flipped without a remount of the view. expect(view.getByTestId('mystery-row')).toBeTruthy() expect(view.queryByText('Tool call')).toBeNull() - await act(async () => { dispose() }) + dispose() + await b.runtime.flush() expect(view.queryByTestId('mystery-row')).toBeNull() expect(view.getByText('Tool call')).toBeTruthy() + await b.runtime.dispose() }) it('a duplicate key registration fails loud at load', async () => { @@ -230,6 +149,7 @@ describe('keyed toolview hole through the real machinery', () => { { name: 'conversation.chat.toolview', key: 'bash' }, () => null, )).toThrow(/key "bash"/) + await b.runtime.dispose() }) it('the inject channel feeds (sessionId) => I into the row component', async () => { @@ -247,66 +167,33 @@ describe('keyed toolview hole through the real machinery', () => { }, ({ mark, poke }: ToolRowProps & { mark: string; poke: () => void }) => ( )) - const view = mountApp(b.slots) + const view = b.runtime.renderRoot() const row = view.getByTestId('probe-row') expect(row.textContent).toBe(`for:${SID}`) row.click() expect(poked).toEqual([SID]) + await b.runtime.dispose() }) }) describe('registrant load-order seam', () => { it("suspends a registrant on inject: ['slots', 'conversation'] until the service (and the hole) exists", async () => { - const ctx = new Context() - const slotsFiber = ctx.plugin(SlotsService) - await slotsFiber.await() - const slots = ctx.get('slots') as SlotsService - ctx.provide('sessions', { - list: createSnapshotStore({ - ids: [], byId: {}, current: undefined, phase: 'ready', - }), - binding: () => undefined, - scope: () => undefined, - provideInfo: () => undefined, - currentProvideInfo: { - getSnapshot: () => ABSENT_INFO, - subscribe: () => () => {}, - }, - provide: () => () => {}, - create: vi.fn(), - open: vi.fn(), - updateIntent: vi.fn(), - }) - ctx.provide('workspaces', { - list: createSnapshotStore({ - items: [], state: 'idle', phase: 'ready', error: null, - baselinesReady: true, recentWorkspaceId: undefined, - }), - startSession: vi.fn(), - sendSession: vi.fn(), - openPath: vi.fn(async () => {}), - }) - ctx.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() }) - ctx.provide('locale', { bind: () => (key: string) => key }) - slots.register({ - name: 'root', - children: { - 'conversation': { kind: 'single', scope: 'session-maybe' }, - 'details': { kind: 'single', scope: 'session' }, - }, - }, AppRoot) + const runtime = await SlotTestRuntime.create() + runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() }) + await runtime.root.declare(LAYOUT_CHILDREN, AppRoot) // Third-party posture, mounted BEFORE ui-conversation: real fiber inject // semantics hold it — apply must not run while 'conversation' is absent. - // (Plain arrow, not vi.fn: mock functions carry a prototype and trip the - // fiber's isConstructor branch.) + // Uses ctx.plugin directly (the deliberate-suspension escape hatch; mount() + // would fail loud on the missing service). (Plain arrow, not vi.fn: mock + // functions carry a prototype and trip the fiber's isConstructor branch.) let applyRuns = 0 - const registrantApply = (registrantCtx: Context): void => { + const registrantApply = (registrantCtx: typeof runtime.ctx): void => { applyRuns += 1 registrantCtx.slots.register( { name: 'conversation.chat.toolview', key: 'late' }, () => null) } - const late = ctx.plugin({ + const late = runtime.ctx.plugin({ name: 'late-registrant', inject: ['slots', 'conversation'], apply: registrantApply, @@ -317,11 +204,11 @@ describe('registrant load-order seam', () => { // Mounting the package resolves the seam: service present ⟹ the chat // entry (and its hole declaration) is already on the ledger, so the // suspended registrant lands without an undeclared-slot throw. - const fiber = ctx.plugin({ inject: [...inject], apply }) - await fiber.await() + await runtime.mount({ inject: [...inject], apply }) await late.await() expect(applyRuns).toBe(1) - expect(slots.entries('conversation.chat.toolview').map(e => e.options.key)) + expect(runtime.slots.entries('conversation.chat.toolview').map(e => e.options.key)) .toEqual(expect.arrayContaining(['bash', 'late'])) + await runtime.dispose() }) }) diff --git a/packages/client/ui-conversation/tests/selection-survival.spec.ts b/packages/client/ui-conversation/tests/selection-survival.spec.ts deleted file mode 100644 index 533d5ce730..0000000000 --- a/packages/client/ui-conversation/tests/selection-survival.spec.ts +++ /dev/null @@ -1,149 +0,0 @@ -// @vitest-environment jsdom -/** - * Exercises selection persistence through the real SlotsService store axis; - * component stubs cannot prove per-session identity or disposal. - */ -import { Context } from 'cordis' -import { beforeEach, describe, expect, it } from 'vitest' -import { createSnapshotStore, SlotsService } from '@deepseek-ai/dsh-client-runtime/client' -import type { SessionId, SessionListState, WorkspaceListState } from '@deepseek-ai/dsh-client-runtime/client' -import { createChatStore } from '../src/client/stores.ts' - -const sid = (s: string): SessionId => s as SessionId - -/** Identity-stable no-session bundle (uSES getSnapshot contract). */ -const ABSENT_INFO = { sessionId: undefined, hooks: {}, props: {} } - -interface Bench { - slots: SlotsService - chat: ReturnType -} - -function bench(): Bench { - const ctx = new Context() - ctx.provide('sessions', { - list: createSnapshotStore({ - ids: [], byId: {}, current: undefined, phase: 'ready', - }), - provideInfo: () => undefined, - currentProvideInfo: { - getSnapshot: () => ABSENT_INFO, - subscribe: () => () => {}, - }, - provide: () => () => {}, - }) - ctx.provide('workspaces', { - list: createSnapshotStore({ - items: [], state: 'idle', phase: 'ready', error: null, - baselinesReady: true, recentWorkspaceId: undefined, - }), - }) - // Service self-registers as ctx 'slots' (cordis Service constructor). - const slots = new SlotsService(ctx) - const chat = createChatStore() - // The apply.ts shape: one shared handle across both session-slot - // registrations. 'conversation'/'details' must first exist in the ledger — - // register a root occupant declaring them (the AppFrame role; the stand-in - // consumes renderSlot to satisfy the declare-means-render check). - slots.register({ - name: 'root', - children: { - 'conversation': { kind: 'single', scope: 'session-maybe' }, - 'conversation.session': { kind: 'single', scope: 'session' }, - 'details': { kind: 'single', scope: 'session' }, - }, - }, (_p: { renderSlot?: unknown }) => null) - // apply.ts mounts the shared chat handle only under session-scope slots - // (the session-maybe 'conversation' shell carries no store). - slots.register({ name: 'conversation.session', store: chat }, () => null) - slots.register({ name: 'details', store: chat }, () => null) - return { slots, chat } -} - -/** Resolve the store instance the renderer would hand a slot's component for a session. */ -function storeFor(b: Bench, slot: 'conversation.session' | 'details', sessionId: SessionId) { - const host = renderHost(b) - const entry = host.entriesOf(slot)[0]! - return host.storeOf(entry, sessionId)! as ReturnType['create']> -} - -/** The host face is only built at renderSlot time; install a stub renderer once to reach it. */ -function renderHost(b: Bench): import('@deepseek-ai/dsh-client-ui-slots').SlotRendererHost { - const captured = (b as unknown as { _host?: import('@deepseek-ai/dsh-client-ui-slots').SlotRendererHost }) - if (captured._host === undefined) { - b.slots.install({ - renderRoot: (host) => { - captured._host = host - return null - }, - }) - b.slots.renderSlot('root', {}) - } - return captured._host! -} - -beforeEach(() => { - localStorage.clear() -}) - -describe('selection survives on the store seat', () => { - it('one session, two slots: conversation writes, details reads the SAME instance', () => { - const b = bench() - - const conv = storeFor(b, 'conversation.session', sid('s1')) - const details = storeFor(b, 'details', sid('s1')) - conv.actions.select({ turnSeq: 3, callId: 'c1' }) - expect(details.store.getSnapshot().selection).toEqual({ turnSeq: 3, callId: 'c1' }) - // Identity, not just value: the shared handle resolves one instance per scope key. - expect(details).toBe(conv) - }) - - it('sessions are isolated: s2 selection never bleeds into s1', () => { - const b = bench() - - const one = storeFor(b, 'conversation.session', sid('s1')) - const two = storeFor(b, 'conversation.session', sid('s2')) - expect(two).not.toBe(one) - one.actions.select({ turnSeq: 1, callId: 'a' }) - two.actions.select({ turnSeq: 9, callId: 'z' }) - expect(one.store.getSnapshot().selection).toEqual({ turnSeq: 1, callId: 'a' }) - expect(two.store.getSnapshot().selection).toEqual({ turnSeq: 9, callId: 'z' }) - }) - - it('a list-projection update keeps instance identity and the selection value', () => { - const b = bench() - const id = sid('s1') - const projection = createSnapshotStore({ displayTitle: 's1' }) - - const store = storeFor(b, 'conversation.session', id) - store.actions.select({ turnSeq: 3, callId: 'c1' }) - store.actions.setDraft('half-typed') - - projection.set({ displayTitle: 'proj-a' }) - expect(projection.getSnapshot().displayTitle).toBe('proj-a') - - const after = storeFor(b, 'conversation.session', id) - expect(after).toBe(store) - expect(after.store.getSnapshot().selection).toEqual({ turnSeq: 3, callId: 'c1' }) - expect(after.store.getSnapshot().draft).toBe('half-typed') - }) - - it('session death buries the instance and its persisted draft', () => { - const b = bench() - - const doomed = storeFor(b, 'conversation.session', sid('s1')) - doomed.actions.setDraft('to be buried') - doomed.actions.select({ turnSeq: 1 }) - expect(localStorage.getItem('dsh.conversation.chat.s1')).not.toBeNull() - - // SessionsService calls this public slot lifecycle seam when the scope dies. - b.slots.pruneStoreScope(sid('s1')) - - // Persisted residue is gone with the session... - expect(localStorage.getItem('dsh.conversation.chat.s1')).toBeNull() - // ...and a re-created same-id session starts from a FRESH instance. - const reborn = storeFor(b, 'conversation.session', sid('s1')) - expect(reborn).not.toBe(doomed) - expect(reborn.store.getSnapshot()).toEqual({ selection: null, draft: '', view: null }) - }) -}) diff --git a/packages/client/ui-conversation/tests/selection-survival.spec.tsx b/packages/client/ui-conversation/tests/selection-survival.spec.tsx new file mode 100644 index 0000000000..70162105a9 --- /dev/null +++ b/packages/client/ui-conversation/tests/selection-survival.spec.tsx @@ -0,0 +1,109 @@ +// @vitest-environment jsdom +/** + * Exercises selection persistence through the real SlotsService store axis; + * component stubs cannot prove per-session identity or disposal. + */ +import { beforeEach, describe, expect, it } from 'vitest' +import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' +import { createChatStore } from '../src/client/stores.ts' + +const sid = (s: string): SessionId => s as SessionId + +type ChatInstance = ReturnType['create']> + +async function bench() { + const runtime = await SlotTestRuntime.create() + const chat = createChatStore() + // The apply.ts shape: one shared handle across both strict-session slot + // registrations ('conversation.session'/'details'); the session-maybe + // 'conversation' shell carries no store by design. The slots must first + // exist in the ledger — the test root declares them (the AppFrame role). + await runtime.root.declare({ + 'conversation': { kind: 'single', scope: 'session-maybe' }, + 'conversation.session': { kind: 'single', scope: 'session' }, + 'details': { kind: 'single', scope: 'session' }, + }, (_p: { renderSlot?: unknown }) => null) + runtime.slots.register({ name: 'conversation.session', store: chat }, () => null) + runtime.slots.register({ name: 'details', store: chat }, () => null) + runtime.renderRoot() // materializes the host face storeOf resolves through + return { runtime, chat } +} + +/** Resolve the store instance the renderer would hand a slot's component for a session. */ +function storeFor(b: Awaited>, slot: 'conversation.session' | 'details', sessionId: SessionId) { + return b.runtime.storeOf(slot, sessionId) as ChatInstance +} + +beforeEach(() => { + localStorage.clear() +}) + +describe('selection survives on the store seat', () => { + it('one session, two slots: conversation writes, details reads the SAME instance', async () => { + const b = await bench() + + const conv = storeFor(b, 'conversation.session', sid('s1')) + const details = storeFor(b, 'details', sid('s1')) + conv.actions.select({ turnSeq: 3, callId: 'c1' }) + expect(details.store.getSnapshot().selection).toEqual({ turnSeq: 3, callId: 'c1' }) + // Identity, not just value: the shared handle resolves one instance per scope key. + expect(details).toBe(conv) + await b.runtime.dispose() + }) + + it('sessions are isolated: s2 selection never bleeds into s1', async () => { + const b = await bench() + + const one = storeFor(b, 'conversation.session', sid('s1')) + const two = storeFor(b, 'conversation.session', sid('s2')) + expect(two).not.toBe(one) + one.actions.select({ turnSeq: 1, callId: 'a' }) + two.actions.select({ turnSeq: 9, callId: 'z' }) + expect(one.store.getSnapshot().selection).toEqual({ turnSeq: 1, callId: 'a' }) + expect(two.store.getSnapshot().selection).toEqual({ turnSeq: 9, callId: 'z' }) + await b.runtime.dispose() + }) + + it('a list-projection update keeps instance identity and the selection value', async () => { + const b = await bench() + const id = sid('s1') + + const store = storeFor(b, 'conversation.session', id) + store.actions.select({ turnSeq: 3, callId: 'c1' }) + store.actions.setDraft('half-typed') + + // A projection churn elsewhere (list rows re-projected) must not touch + // store identity: drive the runtime's own list observable. + await b.runtime.sessions.add({ id, summary: { displayTitle: 'proj-a' } }) + expect(b.runtime.sessions.list.getSnapshot().byId[id]?.displayTitle).toBe('proj-a') + + const after = storeFor(b, 'conversation.session', id) + expect(after).toBe(store) + expect(after.store.getSnapshot().selection).toEqual({ turnSeq: 3, callId: 'c1' }) + expect(after.store.getSnapshot().draft).toBe('half-typed') + await b.runtime.dispose() + }) + + it('session death buries the instance and its persisted draft', async () => { + const b = await bench() + await b.runtime.sessions.add({ id: 's1' }) + + const doomed = storeFor(b, 'conversation.session', sid('s1')) + doomed.actions.setDraft('to be buried') + doomed.actions.select({ turnSeq: 1 }) + expect(localStorage.getItem('dsh.conversation.chat.s1')).not.toBeNull() + + // TestSessions.remove drives the same public slot lifecycle seam the + // production SessionsService calls when the scope dies (pruneStoreScope). + await b.runtime.sessions.remove('s1') + + // Persisted residue is gone with the session... + expect(localStorage.getItem('dsh.conversation.chat.s1')).toBeNull() + // ...and a re-created same-id session starts from a FRESH instance. + const reborn = storeFor(b, 'conversation.session', sid('s1')) + expect(reborn).not.toBe(doomed) + expect(reborn.store.getSnapshot()).toEqual({ selection: null, draft: '', view: null }) + await b.runtime.dispose() + }) +}) diff --git a/packages/client/ui-conversation/tests/service-orchestration.spec.ts b/packages/client/ui-conversation/tests/service-orchestration.spec.ts index b364dde92a..4897fe5769 100644 --- a/packages/client/ui-conversation/tests/service-orchestration.spec.ts +++ b/packages/client/ui-conversation/tests/service-orchestration.spec.ts @@ -1,39 +1,32 @@ // @vitest-environment jsdom +// ConversationService scope addressing over the runtime's real scope tag: +// TestSessions mints tagged scopes through the production createScope, so the +// service's scopeOf/binding path runs against production resolution (no local +// tag probe). import { Context } from 'cordis' import { describe, expect, it, vi } from 'vitest' -import { scopeOf } from '@deepseek-ai/dsh-client-runtime/client' -import type { SessionId, SessionsService } from '@deepseek-ai/dsh-client-runtime/client' +import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' import { ConversationService } from '@deepseek-ai/dsh-client-ui-conversation/client' +import { InputHub } from '../src/client/input/hub.ts' -const sid = (id: string) => id as SessionId -const SCOPE_TAG: symbol = (() => { - const reads: (string | symbol)[] = [] - const proxy = new Proxy(new Context(), { - get(target, property, receiver): unknown { - reads.push(property) - return Reflect.get(target, property, receiver) - }, - }) - void scopeOf(proxy) - return reads.find((value): value is symbol => typeof value === 'symbol')! -})() - -async function bench(withSessions = true) { - const ctx = new Context() +async function bench() { + const runtime = await SlotTestRuntime.create() const prompt = vi.fn(() => Promise.resolve({ ok: true as const, value: { accepted: true as const } })) const cancel = vi.fn(() => Promise.resolve({ ok: true as const, value: { accepted: true as const } })) const loadOlder = vi.fn(() => Promise.resolve()) - const sessions = { - binding: (sessionId: SessionId) => ({ - sessionId, session: { prompt, cancel, loadOlder }, - }), - scopeOf, - } as unknown as SessionsService - if (withSessions) ctx.provide('sessions', sessions) - await ctx.plugin(ConversationService).await() - const root = ctx.get('conversation') as ConversationService - const scoped = ctx.plugin(() => {}).ctx.extend({ [SCOPE_TAG]: sid('s1') }).get('conversation') as ConversationService - return { root, scoped, prompt, cancel, loadOlder } + await runtime.sessions.add({ + id: 's1', + session: { prompt, cancel, loadOlder }, + }) + // config.input is required (the apply shares its hub with the inject + // factories); the bench passes its own instance explicitly. + const fiber = runtime.ctx.plugin(ConversationService, { + input: new InputHub(runtime.ctx), + }) + await fiber.await() + const root = runtime.ctx.get('conversation') as ConversationService + const scoped = runtime.sessions.scope('s1')!.get('conversation') as ConversationService + return { runtime, root, scoped, prompt, cancel, loadOlder } } describe('ConversationService', () => { @@ -45,6 +38,7 @@ describe('ConversationService', () => { expect(b.prompt).toHaveBeenCalledWith([{ type: 'text', text: 'hello' }], 'steer') expect(b.cancel).toHaveBeenCalledOnce() expect(b.loadOlder).toHaveBeenCalledOnce() + await b.runtime.dispose() }) it('folds Session business failures into callback rejections', async () => { @@ -53,12 +47,21 @@ describe('ConversationService', () => { await expect(b.scoped.send('x', 'queue')).rejects.toThrow('conversation.send failed: agent-busy: busy') b.cancel.mockResolvedValueOnce({ ok: false, error: { code: 'internal', message: 'nope', details: {} } } as never) await expect(b.scoped.cancel()).rejects.toThrow('conversation.cancel failed: internal: nope') + await b.runtime.dispose() }) - it('fails loudly from the root scope or without SessionsService', async () => { + it('fails loudly from the root scope, on an unbound session, or without SessionsService', async () => { const b = await bench() await expect(b.root.send('x', 'queue')).rejects.toThrow(/requires a session scope/) - const missing = await bench(false) - await expect(missing.root.send('x', 'queue')).rejects.toThrow(/sessions service unavailable/) + await b.runtime.sessions.remove('s1') + await expect(b.scoped.send('x', 'queue')).rejects.toThrow(/resolved no binding/) + await b.runtime.dispose() + // No SessionsService at all: a bare context (the runtime always provides one). + const bare = new Context() + await bare.plugin(ConversationService, { + input: new InputHub(bare), + }).await() + const orphan = bare.get('conversation') as ConversationService + await expect(orphan.send('x', 'queue')).rejects.toThrow(/sessions service unavailable/) }) }) diff --git a/packages/client/web/package.json b/packages/client/web/package.json index 0bd34e322e..91b14f32d7 100644 --- a/packages/client/web/package.json +++ b/packages/client/web/package.json @@ -31,6 +31,7 @@ "devDependencies": { "@cordisjs/plugin-loader": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", "@types/react-dom": "~18.3.0", diff --git a/packages/client/web/tests/app-shell.spec.tsx b/packages/client/web/tests/app-shell.spec.tsx new file mode 100644 index 0000000000..10acab85d8 --- /dev/null +++ b/packages/client/web/tests/app-shell.spec.tsx @@ -0,0 +1,61 @@ +// @vitest-environment jsdom +/** + * App-shell assembly plugin on the real machinery: bare Context + production + * SlotsService + the test-runtime session/workspace doubles. Deliberately NOT + * mounted through SlotTestRuntime — its create() installs the capturing + * renderer and install() is boot-once; app-shell IS the production installer, + * so this bench hands it the uninstalled service exactly as boot does. + */ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { act, cleanup, render } from '@testing-library/react' +import { Context } from 'cordis' +import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' +import { TestSessions, TestWorkspaces } from '@deepseek-ai/dsh-client-test-runtime' +import type { Stabilizer } from '@deepseek-ai/dsh-client-test-runtime' +import * as AppShell from '@deepseek-ai/dsh-client-web/src/app-shell.ts' + +afterEach(cleanup) + +const stabilize: Stabilizer = async (fn) => { await act(async () => { await fn() }) } + +async function bench() { + const ctx = new Context() + await ctx.plugin(SlotsService).await() + const slots = ctx.get('slots') as SlotsService + ctx.provide('sessions', new TestSessions(stabilize, ctx)) + ctx.provide('workspaces', new TestWorkspaces(stabilize)) + ctx.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() }) + const fiber = ctx.plugin({ inject: [...AppShell.inject], apply: AppShell.apply }) + await fiber.await() + return { ctx, slots, fiber } +} + +describe('app-shell assembly plugin', () => { + it('installs the renderer and provides the assembled appShell face', async () => { + const { ctx, slots } = await bench() + slots.register({ name: 'root' }, () =>
) + const shell = ctx.get('appShell') + expect(shell).toBeDefined() + const view = render(<>{shell!.renderApp()}) + expect(view.getByTestId('root-probe')).toBeTruthy() + }) + + it('assembles once: repeated renderApp calls reuse the built closure', async () => { + const { ctx, slots } = await bench() + slots.register({ name: 'root' }, () =>
) + const shell = ctx.get('appShell')! + const first = render(<>{shell.renderApp()}) + expect(first.getByTestId('root-probe')).toBeTruthy() + first.unmount() + // Second call rides the cached closure (renderApp ??=) and still renders. + const second = render(<>{shell.renderApp()}) + expect(second.getByTestId('root-probe')).toBeTruthy() + }) + + it('fiber dispose retracts the service and uninstalls the renderer', async () => { + const { ctx, slots, fiber } = await bench() + await stabilize(() => fiber.dispose()) + expect(ctx.get('appShell')).toBeUndefined() + expect(() => slots.renderSlot('root', {})).toThrow('not installed') + }) +}) diff --git a/packages/client/web/tests/app.spec.tsx b/packages/client/web/tests/app.spec.tsx new file mode 100644 index 0000000000..b8f5cb2fde --- /dev/null +++ b/packages/client/web/tests/app.spec.tsx @@ -0,0 +1,65 @@ +// @vitest-environment jsdom +/** + * buildRenderApp on SlotTestRuntime: the fail-loud sessions precondition, the + * one ctx-level renderSlot('root') call, and the document-title projection + * arms over the real slot stack. + */ +import { afterEach, describe, expect, it } from 'vitest' +import { cleanup, render } from '@testing-library/react' +import { Context } from 'cordis' +import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' +import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import { buildRenderApp } from '@deepseek-ai/dsh-client-web/src/app.tsx' + +let runtime: SlotTestRuntime | undefined + +afterEach(async () => { + cleanup() + await runtime?.dispose() + runtime = undefined + document.title = '' +}) + +async function bench() { + runtime = await SlotTestRuntime.create() + await runtime.root.declare({}, () =>
) + return { runtime, renderApp: buildRenderApp({ ctx: runtime.ctx }) } +} + +describe('buildRenderApp', () => { + it('fails loud when the sessions service is unavailable', () => { + expect(() => buildRenderApp({ ctx: new Context() })).toThrow('sessions service unavailable') + }) + + it('renders the root slot tree through the one ctx-level renderSlot call', async () => { + const b = await bench() + const view = render(<>{b.renderApp()}) + expect(view.getByTestId('frame')).toBeTruthy() + }) + + it('projects the current session durable title and falls back to the product title', async () => { + document.title = 'Product' + const b = await bench() + render(<>{b.renderApp()}) + // No current session: the product title stands. + expect(document.title).toBe('Product') + await b.runtime.sessions.add({ id: 's1', summary: { title: 'First' } }) + expect(document.title).toBe('First — Product') + await b.runtime.sessions.setCurrent(undefined) + expect(document.title).toBe('Product') + // A session without a durable title keeps the product title. + await b.runtime.sessions.add({ id: 's2' }) + expect(document.title).toBe('Product') + }) + + it('a current id without a list row falls back (selection/list arbitration transient)', async () => { + document.title = 'Product' + const b = await bench() + await b.runtime.sessions.add({ id: 's1', summary: { title: 'First' } }) + render(<>{b.renderApp()}) + expect(document.title).toBe('First — Product') + b.runtime.sessions.list.update((draft) => { draft.current = 'ghost' as SessionId }) + await b.runtime.flush() + expect(document.title).toBe('Product') + }) +})