|
|
@@ -1,37 +1,29 @@
|
|
|
// @vitest-environment jsdom
|
|
|
-
|
|
|
import { describe, expect, it, vi } from 'vitest'
|
|
|
-import { SlotTestRuntime, usePinnedBrowserLanguages, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
|
|
|
-import type { SessionBehaviorOverrides } from '@deepseek-ai/dsh-client-test-runtime'
|
|
|
+import type { ISession } from '@deepseek-ai/dsh-api-session-controller/client'
|
|
|
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
|
|
|
-import type { ClientContext, 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, ConversationSessionHeaderInjected,
|
|
|
- ConversationSessionInjected, DetailsInjected,
|
|
|
+import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
|
|
|
+import {
|
|
|
+ SlotTestRuntime, stubSettingsScope, usePinnedBrowserLanguages,
|
|
|
+} from '@deepseek-ai/dsh-client-test-runtime'
|
|
|
+import type { SessionBehaviorOverrides } from '@deepseek-ai/dsh-client-test-runtime'
|
|
|
+import {
|
|
|
+ apply, inject, type ComposerBarInjected, type ConversationInjected,
|
|
|
+ type ConversationSessionInjected, type ViewTab,
|
|
|
} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
|
|
-import { PendingApproval } from '../src/client/contract/slots.ts'
|
|
|
-import type { createChatStore } from '../src/client/stores.ts'
|
|
|
+import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
|
|
+import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types'
|
|
|
+import { createConversationStore } from '../src/client/stores.ts'
|
|
|
|
|
|
-// The service reads its initial locale from the browser; these specs assert
|
|
|
-// the shipped Chinese copy, so they state the browser they assume.
|
|
|
usePinnedBrowserLanguages('zh-CN')
|
|
|
|
|
|
const ROOT = 'root-1' as SessionId
|
|
|
|
|
|
-type ChatInstance = ReturnType<ReturnType<typeof createChatStore>['create']>
|
|
|
-type ChatActions = ChatInstance['actions']
|
|
|
-type ApprovalOutcome = 'allowed-once' | 'rejected' | 'cancelled' | 'unavailable'
|
|
|
-type ApprovalListener = (
|
|
|
- this: ClientContext,
|
|
|
- request: { toolName: string; callId?: string; reason?: string; signal?: AbortSignal },
|
|
|
- next: () => Promise<ApprovalOutcome>,
|
|
|
-) => Promise<ApprovalOutcome>
|
|
|
+type ConversationInstance = ReturnType<ReturnType<typeof createConversationStore>['create']>
|
|
|
+type ConversationActions = ConversationInstance['actions']
|
|
|
|
|
|
-/** 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<ISession['loadOlder']>(() => Promise.resolve()),
|
|
|
prompt: vi.fn<ISession['prompt']>(() => Promise.resolve({ ok: true, value: { accepted: true } })),
|
|
|
cancel: vi.fn<ISession['cancel']>(() => Promise.resolve({ ok: true, value: { accepted: true } })),
|
|
|
@@ -40,54 +32,32 @@ function sessionFakeFor() {
|
|
|
|
|
|
async function bench() {
|
|
|
const runtime = await SlotTestRuntime.create()
|
|
|
- runtime.provide('connection', { api: { settings: {} }, isLoopback: false })
|
|
|
- let approvalListener: ApprovalListener | undefined
|
|
|
- const remoteOn = vi.fn((event: string, listener: ApprovalListener) => {
|
|
|
- expect(event).toBe('approval/request')
|
|
|
- approvalListener = listener
|
|
|
- return () => { approvalListener = undefined }
|
|
|
- })
|
|
|
- runtime.provide('remote', { $on: remoteOn } as never)
|
|
|
- runtime.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
|
|
|
+ runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
|
|
|
+ const connectWorkspace = vi.spyOn(runtime.ctx.uiSession, 'connectWorkspace').mockResolvedValue(ROOT)
|
|
|
const sessionFake = sessionFakeFor()
|
|
|
await runtime.sessions.add({
|
|
|
id: ROOT,
|
|
|
summary: { title: 'R', displayTitle: 'R', cwd: '/proj' },
|
|
|
session: sessionFake,
|
|
|
- })
|
|
|
- const layoutFake = { openDetails: vi.fn(), closeDetails: vi.fn() }
|
|
|
- runtime.provide('layout', layoutFake)
|
|
|
+ }, { current: false })
|
|
|
const locale = new LocaleRuntime(runtime.ctx)
|
|
|
- runtime.provide('locale', locale)
|
|
|
+ runtime.ctx.provide('locale', locale)
|
|
|
runtime.slots.installLocale(locale)
|
|
|
-
|
|
|
- // 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)
|
|
|
+ }, (_props: { renderSlot?: unknown }) => null)
|
|
|
|
|
|
const feature = await runtime.mount({ inject: [...inject], apply })
|
|
|
-
|
|
|
- // 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.session.header' | 'conversation.composer.bar' | 'conversation.view' | 'details') =>
|
|
|
+ const entryOf = (key: 'conversation' | 'conversation.session' | 'conversation.composer.bar') =>
|
|
|
runtime.slots.entries(key)[0]!
|
|
|
- /** Resolve store instance + call the inject the way the outlet would. */
|
|
|
const conversationApi = (id: SessionId) => {
|
|
|
const entry = entryOf('conversation.session')
|
|
|
- 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 }
|
|
|
- }
|
|
|
- const conversationHeaderApi = (id: SessionId) => {
|
|
|
- const entry = entryOf('conversation.session.header')
|
|
|
- const instance = runtime.storeOf('conversation.session.header', id) as ChatInstance
|
|
|
- const injected = (entry.inject as unknown as (sessionId: SessionId, actions: ChatActions) => ConversationSessionHeaderInjected)(
|
|
|
- id, instance.actions)
|
|
|
+ const instance = runtime.storeOf('conversation.session', id) as ConversationInstance
|
|
|
+ const injected = (entry.inject as unknown as (
|
|
|
+ sessionId: SessionId,
|
|
|
+ actions: ConversationActions,
|
|
|
+ ) => ConversationSessionInjected)(id, instance.actions)
|
|
|
return { instance, injected }
|
|
|
}
|
|
|
const residentApi = (id: SessionId | undefined) => {
|
|
|
@@ -98,330 +68,151 @@ async function bench() {
|
|
|
const entry = entryOf('conversation.composer.bar')
|
|
|
return (entry.inject as unknown as (sessionId: SessionId | undefined) => ComposerBarInjected)(id)
|
|
|
}
|
|
|
- /** Same resolution for the chat entry riding the view ring. */
|
|
|
- const chatViewApi = (id: SessionId) => {
|
|
|
- const entry = entryOf('conversation.view')
|
|
|
- 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 inputApi = (id: SessionId) => {
|
|
|
- const info = runtime.sessions.provideInfo(id)!
|
|
|
- const state = info.hooks['input'] as {
|
|
|
- getSnapshot: () => { draft: string }
|
|
|
- subscribe: (fn: () => void) => () => void
|
|
|
- }
|
|
|
- const actions = info.props['inputActions'] as {
|
|
|
- setDraft: (text: string) => void
|
|
|
- submit: () => void
|
|
|
- }
|
|
|
- return { state, actions }
|
|
|
+ const input = runtime.ctx.conversation.input.for(runtime.sessions.scope(id)!)
|
|
|
+ return { state: input.state, actions: input }
|
|
|
}
|
|
|
+ const viewSource = (id: SessionId): ObservableSnapshot<readonly ViewTab[]> =>
|
|
|
+ conversationApi(id).injected.hooks.conversationViews
|
|
|
return {
|
|
|
- runtime, feature, slots: runtime.slots, entryOf,
|
|
|
- conversationApi, conversationHeaderApi, residentApi, composerApi, chatViewApi, inputApi,
|
|
|
- sessionFake, layoutFake, remoteOn,
|
|
|
- invokeApproval(
|
|
|
- owner: ClientContext,
|
|
|
- request: Parameters<ApprovalListener>[0],
|
|
|
- next: Parameters<ApprovalListener>[1],
|
|
|
- ): Promise<ApprovalOutcome> {
|
|
|
- if (approvalListener === undefined) throw new Error('approval listener was not installed')
|
|
|
- return approvalListener.call(owner, request, next)
|
|
|
- },
|
|
|
+ runtime, feature, slots: runtime.slots, entryOf, conversationApi, residentApi, composerApi,
|
|
|
+ inputApi, viewSource, sessionFake, connectWorkspace,
|
|
|
}
|
|
|
}
|
|
|
|
|
|
-describe('conversation slot inject API', () => {
|
|
|
- it('presents a scoped approval until its Remote Event waterfall resolves', async () => {
|
|
|
- const b = await bench()
|
|
|
- const scope = b.runtime.sessions.scope(ROOT)
|
|
|
- if (scope === undefined) throw new Error('Session scope was not created')
|
|
|
- const next = vi.fn(() => Promise.resolve<ApprovalOutcome>('unavailable'))
|
|
|
- const result = b.invokeApproval(scope, {
|
|
|
- toolName: 'bash', callId: 'call-1', reason: 'needs access',
|
|
|
- }, next)
|
|
|
- const source = b.residentApi(ROOT).hooks.sessionPendingInteraction
|
|
|
- const wait = source.getSnapshot()[0]
|
|
|
- if (wait === undefined || wait.kind !== 'approval') {
|
|
|
- throw new Error('approval wait was not presented')
|
|
|
- }
|
|
|
-
|
|
|
- expect(b.remoteOn).toHaveBeenCalledOnce()
|
|
|
- expect(b.runtime.ctx.conversation.pendingInteractions.statuses.getSnapshot().get(ROOT))
|
|
|
- .toBe('approval')
|
|
|
- await new PendingApproval(wait).answer('allowed-once')
|
|
|
- await expect(result).resolves.toBe('allowed-once')
|
|
|
- expect(next).not.toHaveBeenCalled()
|
|
|
- expect(source.getSnapshot()).toEqual([])
|
|
|
- expect(b.runtime.ctx.conversation.pendingInteractions.statuses.getSnapshot()).toEqual(new Map())
|
|
|
- await b.runtime.dispose()
|
|
|
- })
|
|
|
-
|
|
|
- it('removes a scoped approval when its Remote Event lifetime aborts', async () => {
|
|
|
- const b = await bench()
|
|
|
- const scope = b.runtime.sessions.scope(ROOT)
|
|
|
- if (scope === undefined) throw new Error('Session scope was not created')
|
|
|
- const controller = new AbortController()
|
|
|
- const removeEventListener = vi.spyOn(controller.signal, 'removeEventListener')
|
|
|
- const reason = new DOMException('aborted by Host', 'AbortError')
|
|
|
- const result = b.invokeApproval(scope, {
|
|
|
- toolName: 'bash', signal: controller.signal,
|
|
|
- }, () => Promise.resolve('unavailable'))
|
|
|
- const source = b.residentApi(ROOT).hooks.sessionPendingInteraction
|
|
|
- expect(source.getSnapshot()).toHaveLength(1)
|
|
|
-
|
|
|
- controller.abort(reason)
|
|
|
-
|
|
|
- await expect(result).rejects.toBe(reason)
|
|
|
- expect(removeEventListener).toHaveBeenCalledWith('abort', expect.any(Function))
|
|
|
- expect(source.getSnapshot()).toEqual([])
|
|
|
- await b.runtime.dispose()
|
|
|
- })
|
|
|
-
|
|
|
- it('delegates an approval without a Session-scoped Client Context', async () => {
|
|
|
- const b = await bench()
|
|
|
- const next = vi.fn(() => Promise.resolve<ApprovalOutcome>('unavailable'))
|
|
|
-
|
|
|
- await expect(b.invokeApproval(b.runtime.ctx, { toolName: 'bash' }, next))
|
|
|
- .resolves.toBe('unavailable')
|
|
|
-
|
|
|
- expect(next).toHaveBeenCalledOnce()
|
|
|
- expect(b.runtime.ctx.conversation.pendingInteractions.statuses.getSnapshot()).toEqual(new Map())
|
|
|
- await b.runtime.dispose()
|
|
|
- })
|
|
|
-
|
|
|
- it('assembles the thin API side-effect-free', async () => {
|
|
|
+describe('Conversation inject API', () => {
|
|
|
+ it('assembles the target-neutral read face without Session side effects', async () => {
|
|
|
const b = await bench()
|
|
|
const { injected } = b.conversationApi(ROOT)
|
|
|
- // Assembly has no session side effects: opening the event window belongs
|
|
|
- // to the runtime watch path, not the inject factory.
|
|
|
- expect(b.sessionFake.open).not.toHaveBeenCalled()
|
|
|
- expect(injected.views.list().map(v => v.id)).toEqual(['chat'])
|
|
|
-
|
|
|
- const chatView = b.chatViewApi(ROOT)
|
|
|
- chatView.injected.loadOlder()
|
|
|
- expect(b.sessionFake.loadOlder).toHaveBeenCalledTimes(1)
|
|
|
- chatView.injected.forkAt(17)
|
|
|
- await vi.waitFor(() => {
|
|
|
- expect(b.runtime.sessions.calls).toContainEqual({ method: 'open', args: [ROOT] })
|
|
|
- })
|
|
|
- expect(b.runtime.sessions.calls).toContainEqual({
|
|
|
- method: 'fork', args: [{ sessionId: ROOT, atSeq: 17, increaseTitle: true }],
|
|
|
- })
|
|
|
+ expect(b.sessionFake.loadOlder).not.toHaveBeenCalled()
|
|
|
+ expect(Object.keys(injected)).toEqual(['hooks', 'bindDraftMirror'])
|
|
|
+ expect(b.viewSource(ROOT).getSnapshot()).toEqual([])
|
|
|
await b.runtime.dispose()
|
|
|
})
|
|
|
|
|
|
- it('the provide-channel input face submits through the machine sink: trim, transactional clear, failure retains the draft', async () => {
|
|
|
+ it('submits through the provided input machine and mirrors accepted draft edits', async () => {
|
|
|
const b = await bench()
|
|
|
const { injected } = b.conversationApi(ROOT)
|
|
|
const { state, actions } = b.inputApi(ROOT)
|
|
|
- // Whitespace-only: the machine treats it as empty — no prompt, draft kept.
|
|
|
actions.setDraft(' ')
|
|
|
actions.submit()
|
|
|
expect(b.sessionFake.prompt).not.toHaveBeenCalled()
|
|
|
expect(state.getSnapshot().draft).toBe(' ')
|
|
|
- // Success: the draft clears only after the sink settles.
|
|
|
+
|
|
|
actions.setDraft('hello')
|
|
|
actions.submit()
|
|
|
- await vi.waitFor(() => {
|
|
|
- expect(state.getSnapshot().draft).toBe('')
|
|
|
+ await vi.waitFor(() => { expect(state.getSnapshot().draft).toBe('') })
|
|
|
+ expect(b.sessionFake.prompt).toHaveBeenCalledWith(
|
|
|
+ [{ type: 'text', text: 'hello' }], 'queue', expect.any(AbortSignal),
|
|
|
+ )
|
|
|
+
|
|
|
+ b.sessionFake.prompt.mockResolvedValueOnce({
|
|
|
+ ok: false, error: { code: 'agent-busy', message: 'busy', details: { reason: 'busy' } },
|
|
|
})
|
|
|
- expect(b.sessionFake.prompt).toHaveBeenCalledWith([{ type: 'text', text: 'hello' }], 'queue', expect.any(AbortSignal))
|
|
|
- // Failure: the draft is retained through the round-trip.
|
|
|
- b.sessionFake.prompt.mockResolvedValueOnce({ ok: false, error: { code: 'agent-busy', message: 'b', details: { reason: 'b' } } })
|
|
|
actions.setDraft('retry me')
|
|
|
actions.submit()
|
|
|
- await vi.waitFor(() => {
|
|
|
- expect(b.sessionFake.prompt).toHaveBeenCalledTimes(2)
|
|
|
- })
|
|
|
- await new Promise(r => setTimeout(r, 0))
|
|
|
+ await vi.waitFor(() => { expect(b.sessionFake.prompt).toHaveBeenCalledTimes(2) })
|
|
|
+ await Promise.resolve()
|
|
|
expect(state.getSnapshot().draft).toBe('retry me')
|
|
|
- // Failure landing after new typing: no clobber (the interleaved edit wins).
|
|
|
- b.sessionFake.prompt.mockResolvedValueOnce({ ok: false, error: { code: 'agent-busy', message: 'b', details: { reason: 'b' } } })
|
|
|
- actions.submit()
|
|
|
- actions.setDraft('typed during flight')
|
|
|
- await new Promise(r => setTimeout(r, 0))
|
|
|
- expect(state.getSnapshot().draft).toBe('typed during flight')
|
|
|
- // The provide contribution is idempotent per session: one shell identity.
|
|
|
- expect(b.inputApi(ROOT).state).toBe(state)
|
|
|
- // The draft mirror rides the conversation inject face.
|
|
|
+
|
|
|
const mirrored: string[] = []
|
|
|
const unbind = injected.bindDraftMirror(text => mirrored.push(text))
|
|
|
actions.setDraft('mirrored text')
|
|
|
expect(mirrored).toEqual(['mirrored text'])
|
|
|
unbind()
|
|
|
- // Stop failure is swallowed (promptError owns the display).
|
|
|
- b.sessionFake.cancel.mockResolvedValueOnce({ ok: false, error: { code: 'internal', message: 'x', details: {} } })
|
|
|
+ expect(b.inputApi(ROOT).state).toBe(state)
|
|
|
+
|
|
|
+ b.sessionFake.cancel.mockResolvedValueOnce({
|
|
|
+ ok: false, error: { code: 'internal', message: 'stop failed', details: {} },
|
|
|
+ })
|
|
|
b.composerApi(ROOT).stop!()
|
|
|
- await new Promise(r => setTimeout(r, 0))
|
|
|
- expect(b.sessionFake.cancel).toHaveBeenCalledTimes(1)
|
|
|
+ await vi.waitFor(() => { expect(b.sessionFake.cancel).toHaveBeenCalledOnce() })
|
|
|
await b.runtime.dispose()
|
|
|
})
|
|
|
|
|
|
- it('inject fails loud when the session resolves no binding or the scope lacks the service', async () => {
|
|
|
+ it('fails loud for an unknown binding or an unloaded scoped service', async () => {
|
|
|
const b = await bench()
|
|
|
const entry = b.entryOf('conversation.composer.bar')
|
|
|
- const injectFn = entry.inject as unknown as (sessionId: SessionId | undefined) => ComposerBarInjected
|
|
|
- // Unknown session: the keyboard face's binding resolution answers nothing.
|
|
|
- expect(() => { injectFn('ghost' as SessionId).stop!() }).toThrow(/resolved no binding/)
|
|
|
- // No session (session-maybe absent side): machine faces absent, static
|
|
|
- // hooks compartment still present so the render side's hook order holds.
|
|
|
- const absent = injectFn(undefined)
|
|
|
+ const injectBar = entry.inject as unknown as (
|
|
|
+ sessionId: SessionId | undefined,
|
|
|
+ ) => ComposerBarInjected
|
|
|
+ expect(() => { injectBar('ghost' as SessionId).stop!() }).toThrow(/resolved no binding/)
|
|
|
+
|
|
|
+ const absent = injectBar(undefined)
|
|
|
expect(absent.keyboard).toBeUndefined()
|
|
|
expect(absent.toggleCommandMenu).toBeUndefined()
|
|
|
expect(absent.stop).toBeUndefined()
|
|
|
expect(absent.hooks.notices.getSnapshot()).toBeNull()
|
|
|
expect(absent.hooks.lexicon.getSnapshot().size).toBe(0)
|
|
|
expect(absent.hooks.menuLauncher.getSnapshot()).toBeNull()
|
|
|
- // 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!
|
|
|
+
|
|
|
+ const stop = injectBar(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 () => {
|
|
|
- const b = await bench()
|
|
|
- const { instance, injected } = b.chatViewApi(ROOT)
|
|
|
- injected.openDetails({ turnSeq: 2, callId: 'c1' })
|
|
|
- expect(instance.store.getSnapshot().selection).toEqual({ turnSeq: 2, callId: 'c1' })
|
|
|
- expect(b.layoutFake.openDetails).toHaveBeenCalledTimes(1)
|
|
|
- // The chat view shares the conversation entry's store instance: selection
|
|
|
- // writes land where the skeleton and details read.
|
|
|
- const conv = b.conversationApi(ROOT)
|
|
|
- expect(conv.instance).toBe(instance)
|
|
|
- await b.runtime.dispose()
|
|
|
- })
|
|
|
-
|
|
|
- it('openFile (chat view face) resolves against session cwd and calls workspaces.openPath', async () => {
|
|
|
- const b = await bench()
|
|
|
- const { injected } = b.chatViewApi(ROOT)
|
|
|
- await injected.openFile('src/a.ts')
|
|
|
- await vi.waitFor(() => {
|
|
|
- expect(b.runtime.workspaces.calls).toContainEqual({ method: 'openPath', args: ['/proj/src/a.ts'] })
|
|
|
- })
|
|
|
- await b.runtime.dispose()
|
|
|
- })
|
|
|
-
|
|
|
- it('openFile rejects when the Host cannot open the path', async () => {
|
|
|
- const b = await bench()
|
|
|
- b.runtime.workspaces.stub('openPath', () => Promise.reject(new Error('xdg-open is not available')))
|
|
|
- const { injected } = b.chatViewApi(ROOT)
|
|
|
- await expect(injected.openFile('src/a.ts')).rejects.toThrow('xdg-open is not available')
|
|
|
- await b.runtime.dispose()
|
|
|
- })
|
|
|
-
|
|
|
- it('routes workspace switching through the runtime owner, carrying the draft', async () => {
|
|
|
+ it('moves a draft only when Workspace navigation changes Session', async () => {
|
|
|
const b = await bench()
|
|
|
const resident = b.residentApi(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.inputApi(ROOT)
|
|
|
actions.setDraft('carry me')
|
|
|
- void resident.selectWorkspace('workspace-1' as never)
|
|
|
- await vi.waitFor(() => {
|
|
|
- expect(b.runtime.sessions.calls.filter(c => c.method === 'open')).toHaveLength(1)
|
|
|
- })
|
|
|
- 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
|
|
|
- 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.runtime.sessions.calls).toContainEqual({ method: 'open', args: [OTHER] })
|
|
|
- })
|
|
|
- expect(state.getSnapshot().draft).toBe('')
|
|
|
- expect(b.inputApi(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.residentApi(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] })
|
|
|
- })
|
|
|
+ b.connectWorkspace.mockResolvedValueOnce(ROOT)
|
|
|
+ await resident.selectWorkspace('workspace-1' as WorkspaceId)
|
|
|
+ expect(b.runtime.sessions.calls).toContainEqual({ method: 'open', args: [ROOT] })
|
|
|
+ expect(state.getSnapshot().draft).toBe('carry me')
|
|
|
|
|
|
- // 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.residentApi(ROOT)
|
|
|
- const { state } = b.inputApi(ROOT)
|
|
|
+ const other = 'other-1' as SessionId
|
|
|
+ await b.runtime.sessions.add({ id: other }, { current: false })
|
|
|
+ b.connectWorkspace.mockResolvedValueOnce(other)
|
|
|
+ await resident.selectWorkspace('workspace-2' as WorkspaceId)
|
|
|
+ expect(b.runtime.sessions.calls).toContainEqual({ method: 'open', args: [other] })
|
|
|
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.inputApi(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)
|
|
|
+ expect(b.inputApi(other).state.getSnapshot().draft).toBe('carry me')
|
|
|
await b.runtime.dispose()
|
|
|
})
|
|
|
|
|
|
- it('scopedConversation fails loud when the session resolves no scope', async () => {
|
|
|
+ it('supports no-Session navigation and propagates Workspace connection failure', 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/)
|
|
|
+ b.connectWorkspace.mockResolvedValueOnce(ROOT)
|
|
|
+ await b.residentApi(undefined).selectWorkspace('workspace-0' as WorkspaceId)
|
|
|
+ expect(b.runtime.sessions.calls).toContainEqual({ method: 'open', args: [ROOT] })
|
|
|
+
|
|
|
+ const opens = b.runtime.sessions.calls.filter(call => call.method === 'open').length
|
|
|
+ b.connectWorkspace.mockRejectedValueOnce(new Error('offline'))
|
|
|
+ await expect(b.residentApi(ROOT).selectWorkspace('workspace-4' as WorkspaceId))
|
|
|
+ .rejects.toThrow('offline')
|
|
|
+ expect(b.runtime.sessions.calls.filter(call => call.method === 'open')).toHaveLength(opens)
|
|
|
await b.runtime.dispose()
|
|
|
})
|
|
|
|
|
|
- it('views read face projects the ring ledger (subscribe/version through ctx.slots)', async () => {
|
|
|
+ it('projects the dynamic View registration ledger', async () => {
|
|
|
const b = await bench()
|
|
|
- const { injected } = b.conversationApi(ROOT)
|
|
|
- const before = injected.views.version()
|
|
|
+ const source = b.viewSource(ROOT)
|
|
|
+ const before = source.getSnapshot()
|
|
|
const listener = vi.fn()
|
|
|
- const unsub = injected.views.subscribe(listener)
|
|
|
- // A second ring rider (what ui-trajectory does in production).
|
|
|
- const off = b.slots.register(
|
|
|
- { name: 'conversation.view', id: 'chat2', order: 5, label: 'X' } as never, (() => null) as never)
|
|
|
- await Promise.resolve() // ledger notifications batch per microtask
|
|
|
- expect(listener).toHaveBeenCalled()
|
|
|
- expect(injected.views.version()).toBeGreaterThan(before)
|
|
|
- expect(injected.views.list().map(v => v.id)).toEqual(['chat', 'chat2'])
|
|
|
- // Label falls back to the id when a rider declares none.
|
|
|
- const off2 = b.slots.register(
|
|
|
- { name: 'conversation.view', id: 'bare', order: 6 } as never, (() => null) as never)
|
|
|
- expect(injected.views.list().map(v => v.label)).toEqual(['对话', 'X', 'bare'])
|
|
|
- off()
|
|
|
- off2()
|
|
|
- unsub()
|
|
|
- await b.runtime.dispose()
|
|
|
- })
|
|
|
-})
|
|
|
+ const unsubscribe = source.subscribe(listener)
|
|
|
+ const removeNamed = b.slots.register(
|
|
|
+ { name: 'conversation.view', id: 'trajectory', order: 5, label: 'Trajectory' },
|
|
|
+ (() => null) as never,
|
|
|
+ )
|
|
|
+ await vi.waitFor(() => {
|
|
|
+ expect(source.getSnapshot()).toEqual([{ id: 'trajectory', label: 'Trajectory' }])
|
|
|
+ })
|
|
|
+ expect(listener).toHaveBeenCalledOnce()
|
|
|
+ expect(source.getSnapshot()).not.toBe(before)
|
|
|
|
|
|
-describe('details inject API', () => {
|
|
|
- it('details injects the one layout callback; selection rides the shared store instead', async () => {
|
|
|
- const b = await bench()
|
|
|
- const entry = b.entryOf('details')
|
|
|
- const injected = (entry.inject as unknown as () => DetailsInjected)()
|
|
|
- expect(Object.keys(injected)).toEqual(['closeDetails'])
|
|
|
- injected.closeDetails()
|
|
|
- expect(b.layoutFake.closeDetails).toHaveBeenCalledTimes(1)
|
|
|
- // The shared handle: details resolves the SAME instance conversation writes.
|
|
|
- const conv = b.runtime.storeOf('conversation.session', ROOT)
|
|
|
- const details = b.runtime.storeOf('details', ROOT)
|
|
|
- expect(details).toBe(conv)
|
|
|
+ const removeBare = b.slots.register(
|
|
|
+ { name: 'conversation.view', id: 'bare', order: 6 },
|
|
|
+ (() => null) as never,
|
|
|
+ )
|
|
|
+ await vi.waitFor(() => {
|
|
|
+ expect(source.getSnapshot().map(view => view.label)).toEqual(['Trajectory', 'bare'])
|
|
|
+ })
|
|
|
+ removeNamed()
|
|
|
+ removeBare()
|
|
|
+ unsubscribe()
|
|
|
await b.runtime.dispose()
|
|
|
})
|
|
|
})
|