apps/web/tests/README.md records why these e2e type-check in the Host aggregate and why importing a Client package there pulls its project tree into the Host build graph, with mirroring as the standing answer. The Agent Note drops the directory-picker face split (assessed and declined) and the grep-level gate in favour of that README. docs: regenerate the catalogs and retarget the moved declarations The forwarded-event change moved three owner packages' cordis `Events` declarations and their branded types into client-safe `./types` modules, and the settings-scope split moves the shell spec into ui-settings-general. Point the type-equivalence manifest and the affected Agent Note at those homes, register the new `remote/*` event scope and the `ctx.settingsScope` service in the catalog partition, and re-run the generators. `$on` joins the documented `TypeRTClientRemote` surface, and the two Agent Note fences that quote a bare member signature are marked `ignore-check`: they are declaration fragments, not compilable units. refactor(client): make ui-settings the settings domain's base layer The settings-namespace transport lived in client/runtime, where every feature could value-import it because runtime is a platform module. It belongs to the settings domain, but moving it into ui-settings as a shared function fails twice: the client bundle purity gate forbids cross-plugin value imports, and ui-settings reached ui-sidebar for its shell, so any feature depending on it closed a cycle through ui-layout and ui-theme. Both halves move. `ctx.settingsScope` is now a cordis service — the collaboration shape the purity gate prescribes, and the service proxy binds `this.ctx` to the caller, so a bound scope's disposer belongs to the calling fiber. The shell ui-settings used to own (the `sidebar.settings` occupant, its navigation, and the nav-row projection) moves to ui-settings-general, which already owns the chrome and the General section. What stays in ui-settings is what carries no `ui-*` dependency: the scope service and the canonical settings slot types, `settings.general.item` included. That type was parked in the locale package precisely because the declarer was unreachable without a cycle; every registrant now depends on this base layer, so it comes home. The scope CONTRACT stays in client/runtime: a feature service accepts a scope through its own signature without depending on the surface that binds it. The forwarded settings invalidation replaces the deleted client-side `settings/changed` event, so the transport reads `ctx.remote.$on`. It reaches `$on` through the gateway's Client half plus the allowlist's type-only subpath rather than api-remotes' Client face: that face imports a Host-tsdown-generated artifact, and this package is reachable from the Host build graph through its callers. refactor(client): reach the settings transport through ctx.settingsScope Every feature that owns a preference row switches from value-importing a shared binder to the settings domain's service, and declares the two injections that binding needs: `settingsScope` for the transport and `remote` for the forwarded invalidation it subscribes to on the caller's own context. The rows stay with the features that own the preferences — Language with locale, Appearance with ui-theme, Composer Enter with ui-conversation. Only their route to the transport changes, so no settings surface moves and no feature gains a dependency on the shell. The `settings.general.item` slot type now arrives from ui-settings, the base layer every registrant already depends on, which retires the re-export outlet ui-theme kept and the parked declaration in the locale package. client/runtime drops its settings-form and schemastery dependencies with the transport that used them. test(client): bind the settings transport in the specs that boot a preference row Every bench that activates a plugin owning a preference row now supplies the two services that plugin injects: the forwarded-event port and the scope service. Specs that exercise no settings path get the minimal doubles; the ones that do drive their refresh chains through `remote/host-event`, the same signal client/runtime republishes from a forwarded frame, replacing the deleted client-side `settings/changed` event. Also fixes a publication defect the built-invariant gate catches once it runs: api-remotes' invariant companion shared the allowlist module with the package index, so rolldown hoisted it into a third chunk beside the two bundled entries — a file the mechanically derived publication list does not carry, leaving an installed companion unable to import it. The companion now reads the allowlist through this package's own published `./types` subpath, which the bundle keeps external, so each entry stays self-contained. The dynamic-subscription cast in apiproxy is gone: after the vendored cordis rescope, `on` accepts the rest-parameter handler directly, and the allowlist's shape assertion still carries the safety argument. fix(client): carry the settings-scope move across the release manifests Rebasing onto the publishable release set replaced every manifest's dependency block, so the packages this change touches restate their additions in the workspace-protocol form: the base layer's own transport dependencies, and the `ui-settings` plus `remote` edges each preference-row owner now needs. ui-settings-general takes clsx with the shell it received, and client/runtime drops the settings-form and schemastery dependencies that left with the transport. fix(api-gateway): give each $on subscription its own registration and containment Two defects in the forwarded-event subscription table, both raised in review: A set keyed on listener identity stored one entry when two callers subscribed the same function object to the same event, so the first frame reached it once instead of twice and either disposer silenced the surviving registration. Subscriptions are now records addressed by registration, which is what "the disposer belongs to the calling fiber" requires. A listener declared void may still be `async`, and the synchronous `try/catch` could not see its rejection: the promise was dropped and surfaced as an unhandled rejection outside the documented containment. Delivery now attaches a rejection handler when a listener returns a promise, so both failure modes are logged and isolated alike. Delivery also iterates a snapshot, so a listener that subscribes or disposes during a frame no longer changes who receives that frame, and production matches the TestRemote double instead of relying on live Set iteration order. Both fixes are pinned by tests that fail against the previous implementation. The double gains its own spec for the `$mount` refusal and the unsubscribed-name drop — per-file coverage reaches it — plus a note that it propagates a throwing listener where production contains one, so no spec mistakes it for the containment guarantee. Three prose corrections: `assertJsonArgs` states where its throw actually surfaces (the emitter's listener containment, not load or emit time), the browser e2e README names every standing Client import rather than claiming one exception, and two comments and a test title state the forwarded event instead of the deleted client-side one. refactor(remote): deliver forwarded frames through ctx.remote.$dispatch The carrier used to relay each decoded frame over an internal `remote/host-event` cordis event so the delivery port could stay off the Remote contract. The relay was the wrong shape twice over: it put a client-face event into a scan whose subject is the Host vocabulary, forcing a walk exemption for something that is not a Host event at all, and it made a direct handoff between two Client plugins look like a broadcast any plugin participates in. `TypeRTClientRemote` now carries both roles of one surface — consumers subscribe with `$on`, and whoever owns the Host frame sink hands frames over with `$dispatch` — so client/runtime calls the Remote service directly and the event declaration is gone. A cordis service method is the collaboration shape the client bundle purity gate prescribes, and it needs no relay to satisfy it. The trade is that the handoff is now developer-visible: any plugin holding `ctx.remote` can synthesize a forwarded event. That is the exposure the relay already had — `ctx.emit` was equally reachable — stated in the contract instead of hidden behind a private subscriber. runtime reaches `ctx.remote` through the gateway's Client face rather than api-remotes': that face imports a Host-tsdown-generated artifact, and this project sits in the Host build graph. refactor(api-remotes): keep the allowlist value out of types.ts `src/types.ts` carries only types by package convention, but it held the forwarded-event array, so the type-only subpath published runtime code. The array moves to `src/remote-events.ts` and `types.ts` derives its projection from it; both compiler faces list both files, so the Host forwarding loop and the consumer key face still read one declaration and the package's exports are unchanged. The invariant companion returns to an empty installer. Its dispatch-shape check was the only reason the companion imported the allowlist, which made the two bundled entries share a module: rolldown hoisted it into a third chunk that the mechanically derived publication list does not carry, so an installed companion could not import it. Dropping the check retires that coupling along with the subpath-import and bundle-external workarounds it needed, and the shape the check enforced at runtime is the part the Host face's `TypeRTForwardableEvent` assertion already refuses at compile time. test(ui-task): bind the locale plugin's new injections in its bench The bench boots the real locale plugin, which now injects the settings-scope service and the forwarded-event port, so it stayed pending and left `ctx.locale` undefined. Supplies both doubles like the other benches that boot a plugin owning a preference row. docs: close the documentation gates for the forwarded-event surface Regenerates the two graph catalogs and re-records every bilingual pair this branch edited. Several pairs needed real work beyond the record: - The generators write only the English side, so the Chinese sides of `event-producer-consumer` and `module-graph` had drifted: the former still listed the three deleted client-face events and pointed at declaration sites this branch moved into `types.ts` modules, and the latter carried a stale dependency graph. - `TypeRTClientRemote`'s documented declaration gains `$dispatch` on both sides. - The pairing contract requires both sides to link the same target, so the apiproxy README and the design note now link the English note from both languages, and the note's code blocks are byte-identical across the pair (a translated comment inside a fence counts as divergence). - `apps/web/tests/README.md` gains its Chinese counterpart; the browser e2e lane documents a discipline reviewers apply, so it belongs in the bilingual corpus rather than in the pairing exemption list. - Four fences in the design note are marked `ignore-check`: each quotes a member signature, a union arm, or a snippet that names symbols it does not import, so none is a compilable unit. docs(agent-note): transition the forwarded-event note to implemented The design shipped in this PR, so the pair moves into `implemented/` and takes that folder's skeleton: `## Proposal` becomes a present-tense `## Decision`, and `## Acceptance criteria` plus `## Risks` fold into `## Verification` (what pins the behavior) and `## Consequences` (what the shipped shape costs). Facts that moved after the proposal are corrected rather than preserved: the allowlist value now lives in `remote-events.ts` beside a type-only `types.ts`, the delivery port is `$dispatch` rather than an internal cordis event, and the invariant companion is an explained empty installer. `Verification` states the two `$on` defects the review found — independent registration identity and async-rejection containment — since those are now the properties tests pin. Supersession is partial, so five active notes stay active and gain a cross-link each: `web-config-plane`, `web-client-session-scope`, `config-plane-boundaries`, `versioned-gui-welcome-onboarding`, and `permission-default-for-new-sessions` each described a frame this change replaced. Only the mechanism sentence is annotated; every conclusion those notes own is untouched, and `host/models-changed` remains apiproxy's own derived frame in all of them. Also pins the disposer's idempotence: calling one `$on` disposer twice must not splice a surviving twin registration out from under its owner. fix: docs fix: test
353 lines
17 KiB
TypeScript
353 lines
17 KiB
TypeScript
// @vitest-environment jsdom
|
|
// apply inject factories exercised end to end against the terminal thin
|
|
// API: the strict session API (views triple, draft mirror), the
|
|
// provide-channel input face (machine-sink submit choreography incl.
|
|
// optimistic clear + failure restore), the resident API (selectWorkspace
|
|
// draft carrying), the composer-bar stop face, openDetails = select action +
|
|
// layout orchestration, and the closeDetails details API. 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 APIs 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 { 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 { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
|
|
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, ConversationSessionHeaderInjected,
|
|
ConversationSessionInjected, DetailsInjected,
|
|
} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
|
import type { createChatStore } 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']
|
|
|
|
/** 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 } })),
|
|
} satisfies SessionBehaviorOverrides
|
|
}
|
|
|
|
async function bench() {
|
|
const runtime = await SlotTestRuntime.create()
|
|
runtime.provide('connection', { api: { settings: {} }, isLoopback: false } as never)
|
|
// The plugin injects both; these specs exercise no settings path.
|
|
runtime.provide('remote', { $on: () => () => {} })
|
|
runtime.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
|
|
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)
|
|
const locale = new LocaleService(runtime.ctx)
|
|
runtime.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)
|
|
|
|
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') =>
|
|
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)
|
|
return { instance, injected }
|
|
}
|
|
const residentApi = (id: SessionId | undefined) => {
|
|
const entry = entryOf('conversation')
|
|
return (entry.inject as unknown as (sessionId: SessionId | undefined) => ConversationInjected)(id)
|
|
}
|
|
const composerApi = (id: SessionId | undefined) => {
|
|
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 }
|
|
}
|
|
return {
|
|
runtime, feature, slots: runtime.slots, entryOf,
|
|
conversationApi, conversationHeaderApi, residentApi, composerApi, chatViewApi, inputApi,
|
|
sessionFake, layoutFake,
|
|
}
|
|
}
|
|
|
|
describe('conversation slot inject API', () => {
|
|
it('assembles the thin API side-effect-free', 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 }],
|
|
})
|
|
await b.runtime.dispose()
|
|
})
|
|
|
|
it('the provide-channel input face submits through the machine sink: trim, optimistic clear, failure restore without clobber', 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: cleared and stays cleared.
|
|
actions.setDraft('hello')
|
|
actions.submit()
|
|
expect(state.getSnapshot().draft).toBe('')
|
|
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', details: { reason: 'b' } } })
|
|
actions.setDraft('retry me')
|
|
actions.submit()
|
|
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', 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: {} } })
|
|
b.composerApi(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 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 | 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)
|
|
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!
|
|
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)
|
|
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('routes workspace switching through the runtime owner, carrying the draft', 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] })
|
|
})
|
|
|
|
// 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)
|
|
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)
|
|
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 () => {
|
|
const b = await bench()
|
|
const { injected } = b.conversationApi(ROOT)
|
|
const before = injected.views.version()
|
|
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()
|
|
})
|
|
})
|
|
|
|
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)
|
|
await b.runtime.dispose()
|
|
})
|
|
})
|