Merge remote-tracking branch 'origin/master' into doc/host-client-group-readmes
# Conflicts: # apps/cli/README.i18n.yaml
This commit is contained in:
56
packages/client/runtime/src/client/contract/session.ts
Normal file
56
packages/client/runtime/src/client/contract/session.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* The outward session face. Feature packages never see the concrete Session
|
||||
* class: components read conversation state through `useSession` (the
|
||||
* ObservableSnapshot half), and orchestration code calls the behavior verbs
|
||||
* below — nothing else. Widening this interface is the explicit act of
|
||||
* widening what features may do to a session (and what every test fixture
|
||||
* must stub); runtime-internal entry points (history staging, wire-frame
|
||||
* dispatch) stay on the class, invisible out here.
|
||||
*/
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
|
||||
import type { RpcResult, SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { ConversationSnapshot } from '../sessions/conversation.ts'
|
||||
import type { ObservableSnapshot } from './store.ts'
|
||||
|
||||
/** Key-addressed projection read face (the useProjection resolution path; see ProjectionValueStore). */
|
||||
export interface ProjectionsFace {
|
||||
/**
|
||||
* The identity-stable bare observable for one projection key (absence is
|
||||
* an `undefined` snapshot, never a missing face).
|
||||
* @param key - projection key.
|
||||
* @returns the key's value face.
|
||||
*/
|
||||
faceOf(key: string): ObservableSnapshot<unknown>
|
||||
}
|
||||
|
||||
/** Identity plus the behavior verbs features may invoke on a session. */
|
||||
export interface ISession {
|
||||
/** The session's host identity (agent id — same axis). */
|
||||
readonly sessionId: SessionId
|
||||
/** Host-computed projection values by key (the useProjection seat). */
|
||||
readonly projections: ProjectionsFace
|
||||
/**
|
||||
* Send a prompt into the session.
|
||||
* @param content - model-facing content blocks.
|
||||
* @param mode - 'queue' appends a turn; 'steer' interrupts the running one.
|
||||
* @returns acceptance, or the business error (also mirrored into snapshot.promptError).
|
||||
*/
|
||||
prompt(content: ContentBlock[], mode: 'queue' | 'steer'): Promise<RpcResult<{ accepted: true }>>
|
||||
/**
|
||||
* Cancel the running turn.
|
||||
* @returns acceptance, or the business error.
|
||||
*/
|
||||
cancel(): Promise<RpcResult<{ accepted: true }>>
|
||||
/**
|
||||
* Extend the history window backwards (older messages pagination).
|
||||
* @returns completion; failures land in snapshot.openState/loadingOlder.
|
||||
*/
|
||||
loadOlder(): Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* The full outward face: behavior verbs plus the conversation read side
|
||||
* (the `useSession` hook source). This is the type carried by
|
||||
* `SessionBinding.session` and the provide channel.
|
||||
*/
|
||||
export type SessionFace = ISession & ObservableSnapshot<ConversationSnapshot>
|
||||
47
packages/client/runtime/src/client/contract/sessions-port.ts
Normal file
47
packages/client/runtime/src/client/contract/sessions-port.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Cross-domain sessions face: the contract surface sibling domains (today:
|
||||
* workspaces) consume instead of the sessions implementation. The sessions
|
||||
* domain satisfies it structurally — SessionsService is assignable, checked
|
||||
* wherever the assembly layer or a test injects the real service — so
|
||||
* widening this face is the explicit act of widening the inter-domain
|
||||
* dependency.
|
||||
*/
|
||||
|
||||
import type { SessionId, WorkspaceId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { ObservableSnapshot } from './store.ts'
|
||||
|
||||
/** Session-list row facts sibling domains read: recency, blank-reuse eligibility, and its cwd canon. */
|
||||
export interface SessionsPortSummary {
|
||||
id: SessionId
|
||||
/** Empty-log bit (blank sessions are reused by New Session instead of minting another). */
|
||||
blank: boolean
|
||||
cwd?: string
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
/** Session-list facts sibling domains read: readiness, selection, and the row map. */
|
||||
export interface SessionsPortList {
|
||||
ids: SessionId[]
|
||||
byId: Record<SessionId, SessionsPortSummary>
|
||||
current: SessionId | undefined
|
||||
phase: 'pending' | 'ready'
|
||||
}
|
||||
|
||||
/** The sessions-service face injected into sibling domains. */
|
||||
export interface SessionsPort {
|
||||
/** Observable list snapshot (read face only; writes stay inside the sessions domain). */
|
||||
readonly list: ObservableSnapshot<SessionsPortList>
|
||||
/**
|
||||
* Create a session on the host.
|
||||
* @param opts - target workspace.
|
||||
* @returns the new session id.
|
||||
*/
|
||||
create(opts: { workspaceId: WorkspaceId }): Promise<SessionId>
|
||||
/**
|
||||
* Select a session as current.
|
||||
* @param id - session id (must exist in the list store).
|
||||
*/
|
||||
open(id: SessionId): void
|
||||
/** Clear the current selection into the no-session view state. */
|
||||
clear(): void
|
||||
}
|
||||
64
packages/client/runtime/src/client/contract/sessions.ts
Normal file
64
packages/client/runtime/src/client/contract/sessions.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* The outward sessions-service face — what `ctx.sessions` exposes to feature
|
||||
* packages and the renderer host, and therefore exactly what the test
|
||||
* runtime's sessions double must implement. Wire-pump entry points
|
||||
* (handleMuxEnvelope/handleConnected/refresh) and runtime internals stay on
|
||||
* the concrete class; cross-domain consumers keep the narrower
|
||||
* [SessionsPort](./sessions-port.ts). Widening this interface is the
|
||||
* explicit act of widening what features may do to the sessions domain.
|
||||
*/
|
||||
import type { Context } from 'cordis'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { HostObservable, SessionMaybeProvideInfo } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type {
|
||||
SessionBinding, SessionListState, SessionProvideDescriptor,
|
||||
} from '../sessions/service.ts'
|
||||
import type { SessionFace } from './session.ts'
|
||||
import type { ObservableSnapshot } from './store.ts'
|
||||
|
||||
/** The sessions-service face injected as `ctx.sessions`. */
|
||||
export interface ISessions {
|
||||
/** The useSessions standard feed (list rows + current selection; read face — writes stay inside the domain). */
|
||||
readonly list: ObservableSnapshot<SessionListState>
|
||||
/** Atomic current-session provide projection (the renderer host's `sessions.provideInfo` feed). */
|
||||
readonly currentProvideInfo: HostObservable<SessionMaybeProvideInfo>
|
||||
/**
|
||||
* Select a session as current.
|
||||
* @param id - session id (must exist in the list; unknown ids fail loud).
|
||||
*/
|
||||
open(id: SessionId): void
|
||||
/** Clear the current selection into the no-session view state. */
|
||||
clear(): void
|
||||
/**
|
||||
* Register a per-session standard-props provider (hooks become `use<Name>`
|
||||
* selector hooks on the render side; props spread verbatim).
|
||||
* @param descriptor - static member roster plus per-session resolver.
|
||||
* @returns disposer removing the provider.
|
||||
*/
|
||||
provide(descriptor: SessionProvideDescriptor): () => void
|
||||
/**
|
||||
* Resolve an Agent-scoped context view (use-and-discard).
|
||||
* @param id - session id.
|
||||
* @returns scoped ctx, or undefined for a session neither listed nor already scoped.
|
||||
*/
|
||||
scope(id: SessionId): Context | undefined
|
||||
/**
|
||||
* Read the Agent scope tag off a context (service-method seam: fetch
|
||||
* bundles must reach scope resolution through ctx.sessions).
|
||||
* @param ctx - any client context.
|
||||
* @returns the session id, or undefined on root contexts.
|
||||
*/
|
||||
scopeOf(ctx: Context): SessionId | undefined
|
||||
/**
|
||||
* Resolve the session face behind an Agent-scoped context.
|
||||
* @param ctx - an Agent-scoped context.
|
||||
* @returns the session face, or undefined when the ctx is untagged or its scope was pruned.
|
||||
*/
|
||||
sessionOf(ctx: Context): SessionFace | undefined
|
||||
/**
|
||||
* Resolve the stable session binding (scope-addressed assembly feed).
|
||||
* @param id - session id.
|
||||
* @returns binding, or undefined for a session neither listed nor already scoped.
|
||||
*/
|
||||
binding(id: SessionId): SessionBinding | undefined
|
||||
}
|
||||
65
packages/client/runtime/src/client/contract/workspaces.ts
Normal file
65
packages/client/runtime/src/client/contract/workspaces.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* The outward workspaces-service face — what `ctx.workspaces` exposes to
|
||||
* feature packages and the renderer host, and therefore exactly what the
|
||||
* test runtime's workspaces double must implement. Wire-pump entry points
|
||||
* (handleHostEnvelope/handleConnected/refresh/startInitialSelection) stay on
|
||||
* the concrete class. Widening this interface is the explicit act of
|
||||
* widening what features may do to the workspaces domain.
|
||||
*/
|
||||
import type { SessionId, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { WorkspaceListState } from '../workspaces/service.ts'
|
||||
import type { ObservableSnapshot } from './store.ts'
|
||||
|
||||
/** The workspaces-service face injected as `ctx.workspaces`. */
|
||||
export interface IWorkspaces {
|
||||
/** The useWorkspaces standard feed (read face — writes stay inside the domain). */
|
||||
readonly list: ObservableSnapshot<WorkspaceListState>
|
||||
/**
|
||||
* Connect a Workspace to its reusable or freshly created blank session.
|
||||
* @param workspaceId - target workspace.
|
||||
* @returns the connected session id.
|
||||
*/
|
||||
connectWorkspace(workspaceId: WorkspaceId): Promise<SessionId>
|
||||
/**
|
||||
* The New Session flow: connect the target (or recent) Workspace and open
|
||||
* the resulting session; failures surface on the session list state.
|
||||
* @param workspaceId - explicit target; omitted uses the recency projection.
|
||||
*/
|
||||
startSession(workspaceId?: WorkspaceId): void
|
||||
/**
|
||||
* Create a Workspace by name or register an existing path.
|
||||
* @param input - exactly one Host create spelling.
|
||||
* @returns the created or idempotently resolved Workspace.
|
||||
*/
|
||||
create(input: { name: string } | { path: string }): Promise<WorkspaceView>
|
||||
/**
|
||||
* Open the Host's native directory picker.
|
||||
* @returns the selected path, or null when the user cancelled.
|
||||
*/
|
||||
pickDirectory(): Promise<string | null>
|
||||
/**
|
||||
* Open a filesystem path with the Host operating system's default application.
|
||||
* @param path - absolute or host-resolvable path.
|
||||
*/
|
||||
openPath(path: string): Promise<void>
|
||||
/**
|
||||
* Rename a Workspace.
|
||||
* @param workspaceId - target workspace.
|
||||
* @param title - the new display title.
|
||||
* @returns the updated Workspace view.
|
||||
*/
|
||||
rename(workspaceId: WorkspaceId, title: string): Promise<WorkspaceView>
|
||||
/**
|
||||
* Delete a Workspace (its sessions fall back to the unaccounted group).
|
||||
* @param workspaceId - target workspace.
|
||||
*/
|
||||
delete(workspaceId: WorkspaceId): Promise<void>
|
||||
/**
|
||||
* Move an accounted session within/into a Workspace's ordered list.
|
||||
* @param workspaceId - target workspace.
|
||||
* @param sessionId - accounted session to move.
|
||||
* @param beforeSessionId - accounted anchor to insert before; omitted appends.
|
||||
* @returns the updated Workspace view.
|
||||
*/
|
||||
insertSessionBefore(workspaceId: WorkspaceId, sessionId: SessionId, beforeSessionId?: SessionId): Promise<WorkspaceView>
|
||||
}
|
||||
@@ -12,10 +12,17 @@ import type { UseProjection } from './sessions/projection-store.ts'
|
||||
export { SlotsService } from './slots.ts'
|
||||
export type { RootOwnerProps } from './slots.ts'
|
||||
export { SessionCreateError, SessionsService, scopeOf, workspaceTitleOf } from './sessions/service.ts'
|
||||
// The provide channel is shared with the client test runtime (one
|
||||
// materialization/projection implementation; no test-side mirror to drift).
|
||||
export { SessionProvideChannel } from './sessions/provide.ts'
|
||||
export type { SessionProvideChannelHost } from './sessions/provide.ts'
|
||||
export { createScope } from './agents/scope.ts'
|
||||
export type { AgentScopeHandle } from './agents/scope.ts'
|
||||
export { WorkspaceCreateError, WorkspacesService } from './workspaces/service.ts'
|
||||
export type { Session } from './sessions/session.ts'
|
||||
export type { ISession, ProjectionsFace, SessionFace } from './contract/session.ts'
|
||||
export type { ISessions } from './contract/sessions.ts'
|
||||
export type { IWorkspaces } from './contract/workspaces.ts'
|
||||
export type {
|
||||
SessionBinding, SessionListState, SessionProvideContribution, SessionProvideDescriptor, SessionSummary,
|
||||
} from './sessions/service.ts'
|
||||
@@ -109,8 +116,10 @@ declare module 'cordis' {
|
||||
}
|
||||
interface Context {
|
||||
slots: import('./slots.ts').SlotsService
|
||||
sessions: import('./sessions/service.ts').SessionsService
|
||||
workspaces: import('./workspaces/service.ts').WorkspacesService
|
||||
/** The outward face only; the concrete service stays inside the runtime. */
|
||||
sessions: import('./contract/sessions.ts').ISessions
|
||||
/** The outward face only; the concrete service stays inside the runtime. */
|
||||
workspaces: import('./contract/workspaces.ts').IWorkspaces
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -212,6 +212,19 @@ export class SessionManager {
|
||||
session.handleBlank(s.blank)
|
||||
session.handleRunning(s.running)
|
||||
}
|
||||
// Seed each row's projection baseline into the per-session value
|
||||
// store (cold titles surface without opening the session). Per-key
|
||||
// apply, not seed(): the list block is a partial baseline — the
|
||||
// cold cache serves only version-matching keys — so an absent key
|
||||
// must not clear; higher-seq-wins still keeps a stale list block
|
||||
// from overwriting a newer push frame or tail baseline.
|
||||
for (const s of result.value.items) {
|
||||
const block = s.projections
|
||||
if (block === undefined) continue
|
||||
const store = this.projectionStore(s.sessionId)
|
||||
const values = block.values as Record<string, unknown>
|
||||
for (const key of Object.keys(values)) store.apply(key, values[key], block.asOfSeq)
|
||||
}
|
||||
} else {
|
||||
this.listState = 'error'
|
||||
this.listError = result.error
|
||||
|
||||
190
packages/client/runtime/src/client/sessions/provide.ts
Normal file
190
packages/client/runtime/src/client/sessions/provide.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* The session standard-props provide channel: provider roster, bundle
|
||||
* materialization (fail-loud on undeclared/missing/duplicate members), the
|
||||
* static no-session projection, and the atomic current-session projection
|
||||
* observable. One implementation — SessionsService drives it from wire
|
||||
* truth, the test runtime's sessions double drives it from fixtures — so
|
||||
* the materialization rules and the projection semantics cannot drift
|
||||
* between production and the test bench.
|
||||
*/
|
||||
import type { HostObservable, SessionMaybeProvideInfo, SessionProvideInfo } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { SessionBinding, SessionProvideDescriptor } from './service.ts'
|
||||
|
||||
/** The owner-side hooks: how the channel reaches the owner's live bundles and current selection. */
|
||||
export interface SessionProvideChannelHost {
|
||||
/**
|
||||
* Re-materialize every already-materialized bundle against the new roster
|
||||
* (call {@link SessionProvideChannel.materializeInfo} per live binding).
|
||||
* Lazily-materialized sessions pick the new roster up on first resolve.
|
||||
*/
|
||||
rebuildBundles(): void
|
||||
/** Resolve the current selection's bundle (the owner's maybe-provide lookup). */
|
||||
resolveCurrent(): SessionMaybeProvideInfo
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider roster + materialization + current projection. The channel owns
|
||||
* every rule a provider contribution must satisfy; owners keep only their
|
||||
* per-session bundle storage and the definition of "current".
|
||||
*/
|
||||
export class SessionProvideChannel {
|
||||
private readonly providers: SessionProvideDescriptor[] = []
|
||||
private maybeInfoCache: SessionMaybeProvideInfo
|
||||
/** Latest published current bundle (identity comparison dedupes republish). */
|
||||
private currentSnapshot: SessionMaybeProvideInfo
|
||||
/** Projection subscribers (plain cell: bundles hold live session sources, so no store freeze may touch them). */
|
||||
private readonly listeners = new Set<() => void>()
|
||||
|
||||
/**
|
||||
* Atomic current-session provide projection: selection changes and
|
||||
* provider-roster changes publish through this one source, so a roster
|
||||
* change under a stable current id republishes the bundle instead of
|
||||
* stranding mounted entries.
|
||||
*/
|
||||
readonly currentProvideInfo: HostObservable<SessionMaybeProvideInfo>
|
||||
|
||||
/**
|
||||
* @param host - owner-side bundle storage and current-selection resolution.
|
||||
*/
|
||||
constructor(private readonly host: SessionProvideChannelHost) {
|
||||
// The runtime's own contribution comes first: useSession rides the same
|
||||
// provide channel every plugin uses (no renderer special case).
|
||||
this.providers.push({
|
||||
hooks: ['session'],
|
||||
resolve: binding => ({ hooks: { session: binding.session } }),
|
||||
})
|
||||
this.maybeInfoCache = this.materializeMaybeInfo()
|
||||
this.currentSnapshot = this.maybeInfoCache
|
||||
this.currentProvideInfo = {
|
||||
getSnapshot: () => this.currentSnapshot,
|
||||
subscribe: (fn) => {
|
||||
this.listeners.add(fn)
|
||||
return () => { this.listeners.delete(fn) }
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** The static no-session projection under the current roster (declared names present, values undefined). */
|
||||
get maybeInfo(): SessionMaybeProvideInfo {
|
||||
return this.maybeInfoCache
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a per-session standard-props provider (see
|
||||
* SessionsService.provide for the product contract). Live bundles rebuild
|
||||
* immediately; misdeclared providers fail loud here, at the registration
|
||||
* edge, and the registration rolls back — the channel never stays on a
|
||||
* roster it cannot materialize.
|
||||
* @param descriptor - static member roster plus per-session resolver.
|
||||
* @returns disposer removing the provider.
|
||||
*/
|
||||
provide(descriptor: SessionProvideDescriptor): () => void {
|
||||
this.providers.push(descriptor)
|
||||
try {
|
||||
this.applyRosterChange()
|
||||
} catch (error) {
|
||||
this.providers.splice(this.providers.indexOf(descriptor), 1)
|
||||
// Restore the previous (valid) roster's bundles; cannot rethrow — the
|
||||
// pre-push roster materialized successfully before.
|
||||
this.applyRosterChange()
|
||||
throw error
|
||||
}
|
||||
return () => {
|
||||
const at = this.providers.indexOf(descriptor)
|
||||
if (at >= 0) this.providers.splice(at, 1)
|
||||
this.applyRosterChange()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-derive the current selection's bundle and publish it when it changed.
|
||||
* Bundles are identity-stable per (scope, roster) materialization, so an
|
||||
* identity compare is exact; synchronous notify — call sites (the owner's
|
||||
* list subscription, provide()) already sit behind their own batching or
|
||||
* registration edges.
|
||||
*/
|
||||
publishCurrent(): void {
|
||||
const next = this.host.resolveCurrent()
|
||||
if (next === this.currentSnapshot) return
|
||||
this.currentSnapshot = next
|
||||
for (const fn of [...this.listeners]) {
|
||||
try {
|
||||
fn()
|
||||
} catch (error) {
|
||||
// Contain subscriber failures: this notify runs inside the list
|
||||
// notification, where a throwing render-side subscriber would starve
|
||||
// later listeners and abort the projection pass that scheduled it.
|
||||
console.error('sessions.currentProvideInfo subscriber failed:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Materialize the standard-props bundle for one session (fails loud on
|
||||
* undeclared, missing, and duplicate member names).
|
||||
* @param binding - session assembly handle fed to every resolver.
|
||||
* @returns the materialized bundle (identity-stable until the next materialization).
|
||||
*/
|
||||
materializeInfo(binding: SessionBinding): SessionProvideInfo {
|
||||
const hooks: Record<string, HostObservable<unknown>> = {}
|
||||
const props: Record<string, unknown> = {}
|
||||
for (const descriptor of this.providers) {
|
||||
const contribution = descriptor.resolve(binding)
|
||||
const contributedHooks = contribution.hooks ?? {}
|
||||
const contributedProps = contribution.props ?? {}
|
||||
for (const name of Object.keys(contributedHooks)) {
|
||||
if (!(descriptor.hooks ?? []).includes(name)) {
|
||||
throw new Error(`sessions.provide: undeclared hook "${name}"`)
|
||||
}
|
||||
}
|
||||
for (const name of Object.keys(contributedProps)) {
|
||||
if (!(descriptor.props ?? []).includes(name)) {
|
||||
throw new Error(`sessions.provide: undeclared prop "${name}"`)
|
||||
}
|
||||
}
|
||||
for (const name of descriptor.hooks ?? []) {
|
||||
const source = contributedHooks[name]
|
||||
if (source === undefined) throw new Error(`sessions.provide: missing hook "${name}"`)
|
||||
if (Object.hasOwn(hooks, name)) throw new Error(`sessions.provide: duplicate hook "${name}"`)
|
||||
hooks[name] = source
|
||||
}
|
||||
for (const name of descriptor.props ?? []) {
|
||||
if (!Object.hasOwn(contributedProps, name)) throw new Error(`sessions.provide: missing prop "${name}"`)
|
||||
if (Object.hasOwn(props, name)) throw new Error(`sessions.provide: duplicate prop "${name}"`)
|
||||
props[name] = contributedProps[name]
|
||||
}
|
||||
}
|
||||
return {
|
||||
sessionId: binding.sessionId,
|
||||
hooks,
|
||||
props,
|
||||
// The useProjection seat: key-addressed bare value faces off the
|
||||
// session's projection store (open key space — never a static roster member).
|
||||
projections: { faceOf: key => binding.session.projections.faceOf(key) },
|
||||
}
|
||||
}
|
||||
|
||||
/** Rebuild the static projection and the owner's live bundles, then republish the current one. */
|
||||
private applyRosterChange(): void {
|
||||
this.maybeInfoCache = this.materializeMaybeInfo()
|
||||
this.host.rebuildBundles()
|
||||
this.publishCurrent()
|
||||
}
|
||||
|
||||
/** Build the static no-session kit and reject duplicate declared names. */
|
||||
private materializeMaybeInfo(): SessionMaybeProvideInfo {
|
||||
const hooks: Record<string, undefined> = {}
|
||||
const props: Record<string, undefined> = {}
|
||||
for (const descriptor of this.providers) {
|
||||
for (const name of descriptor.hooks ?? []) {
|
||||
if (Object.hasOwn(hooks, name)) throw new Error(`sessions.provide: duplicate hook "${name}"`)
|
||||
hooks[name] = undefined
|
||||
}
|
||||
for (const name of descriptor.props ?? []) {
|
||||
if (Object.hasOwn(props, name)) throw new Error(`sessions.provide: duplicate prop "${name}"`)
|
||||
props[name] = undefined
|
||||
}
|
||||
}
|
||||
return { sessionId: undefined, hooks, props } // no projections face: every key reads absent without a session
|
||||
}
|
||||
}
|
||||
@@ -22,9 +22,12 @@ import type {
|
||||
} from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { SnapshotStore } from '../contract/store.ts'
|
||||
import { createSnapshotStore } from '../contract/store.ts'
|
||||
import type { SessionFace } from '../contract/session.ts'
|
||||
import type { ISessions } from '../contract/sessions.ts'
|
||||
import { createScope, scopeOf as scopeTagOf } from '../agents/scope.ts'
|
||||
import { SessionManager } from './manager.ts'
|
||||
import type { SessionListPhase } from './manager.ts'
|
||||
import { SessionProvideChannel } from './provide.ts'
|
||||
import type { Session } from './session.ts'
|
||||
|
||||
/** Session list row projected from the host list RPC plus live stream increments. */
|
||||
@@ -79,7 +82,8 @@ export class SessionCreateError extends Error {
|
||||
/** Session assembly handle for SessionProvider/inject factories (identity-stable per session). */
|
||||
export interface SessionBinding {
|
||||
readonly sessionId: SessionId
|
||||
readonly session: Session
|
||||
/** The outward session face only — feature code never sees the concrete class. */
|
||||
readonly session: SessionFace
|
||||
readonly ctx: Context
|
||||
}
|
||||
|
||||
@@ -119,6 +123,8 @@ interface ScopeRecord {
|
||||
fiber: Fiber
|
||||
ctx: Context
|
||||
binding: SessionBinding
|
||||
/** The concrete Session for runtime-internal entry points (staging open()); the binding carries only the outward face. */
|
||||
session: Session
|
||||
/** Render-layer standard-props bundle (identity-stable per scope; the renderer's per-info caches key off it). */
|
||||
provideInfo: SessionProvideInfo
|
||||
}
|
||||
@@ -146,7 +152,7 @@ export interface SessionProvideDescriptor {
|
||||
}
|
||||
|
||||
/** Root sessions service: list store, current selection, object-layer manager, scope tree, bindings, ancestry. */
|
||||
export class SessionsService {
|
||||
export class SessionsService implements ISessions {
|
||||
/** List snapshot store (list RPC + host stream increments; re-pulled on reconnect) — the useSessions standard feed, current included. */
|
||||
readonly list: SnapshotStore<SessionListState>
|
||||
/** The object-layer instance cluster and frame dispatch entry. */
|
||||
@@ -170,14 +176,8 @@ export class SessionsService {
|
||||
private readonly selection: SnapshotStore<{ sessionId?: SessionId }>
|
||||
|
||||
private readonly scopes = new Map<SessionId, ScopeRecord>()
|
||||
/** Registered per-session standard-props providers, in registration order. */
|
||||
private readonly providers: SessionProvideDescriptor[] = []
|
||||
/** Static no-session projection, rebuilt only when the provider roster changes. */
|
||||
private maybeInfo: SessionMaybeProvideInfo
|
||||
/** Latest published {@link SessionsService.currentProvideInfo} bundle (identity comparison dedupes republish). */
|
||||
private currentProvideInfoSnapshot: SessionMaybeProvideInfo
|
||||
/** currentProvideInfo subscribers (plain cell: bundles hold live Session sources, so no store freeze may touch them). */
|
||||
private readonly currentProvideInfoListeners = new Set<() => void>()
|
||||
/** The provide channel (roster, materialization rules, current projection) — shared with the test runtime's double. */
|
||||
private readonly provideChannel: SessionProvideChannel
|
||||
/**
|
||||
* The staged session id — follows `list.current` exactly, holding its last
|
||||
* defined value across masked gaps (a transiently absent selection blanks
|
||||
@@ -212,23 +212,17 @@ export class SessionsService {
|
||||
// The current-provide projection follows the same current writes.
|
||||
this.list.subscribe(() => {
|
||||
this.followCurrent()
|
||||
this.updateCurrentProvideInfo()
|
||||
this.provideChannel.publishCurrent()
|
||||
})
|
||||
// The runtime's own contribution comes first: useSession rides the same
|
||||
// provide channel every plugin uses (no renderer special case).
|
||||
this.providers.push({
|
||||
hooks: ['session'],
|
||||
resolve: binding => ({ hooks: { session: binding.session } }),
|
||||
})
|
||||
this.maybeInfo = this.materializeMaybeProvideInfo()
|
||||
this.currentProvideInfoSnapshot = this.maybeInfo
|
||||
this.currentProvideInfo = {
|
||||
getSnapshot: () => this.currentProvideInfoSnapshot,
|
||||
subscribe: (fn) => {
|
||||
this.currentProvideInfoListeners.add(fn)
|
||||
return () => { this.currentProvideInfoListeners.delete(fn) }
|
||||
this.provideChannel = new SessionProvideChannel({
|
||||
rebuildBundles: () => {
|
||||
for (const record of this.scopes.values()) {
|
||||
record.provideInfo = this.provideChannel.materializeInfo(record.binding)
|
||||
}
|
||||
},
|
||||
}
|
||||
resolveCurrent: () => this.maybeProvideInfo(this.list.getSnapshot().current),
|
||||
})
|
||||
this.currentProvideInfo = this.provideChannel.currentProvideInfo
|
||||
rootCtx.reflect.provide('sessions', this, undefined)
|
||||
}
|
||||
|
||||
@@ -243,105 +237,10 @@ export class SessionsService {
|
||||
* @returns disposer removing the provider (already-materialized bundles keep their members until their scope drops).
|
||||
*/
|
||||
provide(descriptor: SessionProvideDescriptor): () => void {
|
||||
this.providers.push(descriptor)
|
||||
// Scopes may already exist (boot order: the list lands and resolves
|
||||
// scopes before later plugins register) — their bundles must include
|
||||
// every provider by first render, so re-materialize on roster change.
|
||||
this.rematerializeProvideBundles()
|
||||
return () => {
|
||||
const at = this.providers.indexOf(descriptor)
|
||||
if (at >= 0) this.providers.splice(at, 1)
|
||||
this.rematerializeProvideBundles()
|
||||
}
|
||||
}
|
||||
|
||||
/** Rebuild every live scope's standard-props bundle after a provider roster change. */
|
||||
private rematerializeProvideBundles(): void {
|
||||
this.maybeInfo = this.materializeMaybeProvideInfo()
|
||||
for (const record of this.scopes.values()) {
|
||||
record.provideInfo = this.materializeProvideInfo(record.binding)
|
||||
}
|
||||
this.updateCurrentProvideInfo()
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-derive the current selection's provide bundle and publish it when it
|
||||
* changed. Bundles are identity-stable per (scope, roster)
|
||||
* materialization, so an identity compare is exact; synchronous notify —
|
||||
* both call sites (list.subscribe, provide()) already sit behind their own
|
||||
* batching or registration edges.
|
||||
*/
|
||||
private updateCurrentProvideInfo(): void {
|
||||
const next = this.maybeProvideInfo(this.list.getSnapshot().current)
|
||||
if (next === this.currentProvideInfoSnapshot) return
|
||||
this.currentProvideInfoSnapshot = next
|
||||
for (const fn of [...this.currentProvideInfoListeners]) {
|
||||
try {
|
||||
fn()
|
||||
} catch (error) {
|
||||
// Contain subscriber failures: this notify runs inside the list
|
||||
// notification, where a throwing render-side subscriber would starve
|
||||
// later listeners and abort the projection pass that scheduled it.
|
||||
console.error('sessions.currentProvideInfo subscriber failed:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Build the static no-session kit and reject duplicate declared names. */
|
||||
private materializeMaybeProvideInfo(): SessionMaybeProvideInfo {
|
||||
const hooks: Record<string, undefined> = {}
|
||||
const props: Record<string, undefined> = {}
|
||||
for (const descriptor of this.providers) {
|
||||
for (const name of descriptor.hooks ?? []) {
|
||||
if (Object.hasOwn(hooks, name)) throw new Error(`sessions.provide: duplicate hook "${name}"`)
|
||||
hooks[name] = undefined
|
||||
}
|
||||
for (const name of descriptor.props ?? []) {
|
||||
if (Object.hasOwn(props, name)) throw new Error(`sessions.provide: duplicate prop "${name}"`)
|
||||
props[name] = undefined
|
||||
}
|
||||
}
|
||||
return { sessionId: undefined, hooks, props } // no projections face: every key reads absent without a session
|
||||
}
|
||||
|
||||
/** Materialize the standard-props bundle for one session (fails loud on duplicate member names). */
|
||||
private materializeProvideInfo(binding: SessionBinding): SessionProvideInfo {
|
||||
const hooks: Record<string, HostObservable<unknown>> = {}
|
||||
const props: Record<string, unknown> = {}
|
||||
for (const descriptor of this.providers) {
|
||||
const contribution = descriptor.resolve(binding)
|
||||
const contributedHooks = contribution.hooks ?? {}
|
||||
const contributedProps = contribution.props ?? {}
|
||||
for (const name of Object.keys(contributedHooks)) {
|
||||
if (!(descriptor.hooks ?? []).includes(name)) {
|
||||
throw new Error(`sessions.provide: undeclared hook "${name}"`)
|
||||
}
|
||||
}
|
||||
for (const name of Object.keys(contributedProps)) {
|
||||
if (!(descriptor.props ?? []).includes(name)) {
|
||||
throw new Error(`sessions.provide: undeclared prop "${name}"`)
|
||||
}
|
||||
}
|
||||
for (const name of descriptor.hooks ?? []) {
|
||||
const source = contributedHooks[name]
|
||||
if (source === undefined) throw new Error(`sessions.provide: missing hook "${name}"`)
|
||||
if (Object.hasOwn(hooks, name)) throw new Error(`sessions.provide: duplicate hook "${name}"`)
|
||||
hooks[name] = source
|
||||
}
|
||||
for (const name of descriptor.props ?? []) {
|
||||
if (!Object.hasOwn(contributedProps, name)) throw new Error(`sessions.provide: missing prop "${name}"`)
|
||||
if (Object.hasOwn(props, name)) throw new Error(`sessions.provide: duplicate prop "${name}"`)
|
||||
props[name] = contributedProps[name]
|
||||
}
|
||||
}
|
||||
return {
|
||||
sessionId: binding.sessionId,
|
||||
hooks,
|
||||
props,
|
||||
// The useProjection seat: key-addressed bare value faces off the
|
||||
// session's projection store (open key space — never a static roster member).
|
||||
projections: { faceOf: key => binding.session.projections.faceOf(key) },
|
||||
}
|
||||
// scopes before later plugins register) — the channel rebuilds their
|
||||
// bundles through the host hooks so every provider lands by first render.
|
||||
return this.provideChannel.provide(descriptor)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -439,9 +338,9 @@ export class SessionsService {
|
||||
* `agent.session`). Same service-method seam as
|
||||
* {@link SessionsService.scopeOf}.
|
||||
* @param ctx - an Agent-scoped context.
|
||||
* @returns the Session, or undefined when the ctx is untagged or its scope was pruned.
|
||||
* @returns the session face, or undefined when the ctx is untagged or its scope was pruned.
|
||||
*/
|
||||
sessionOf(ctx: Context): Session | undefined {
|
||||
sessionOf(ctx: Context): SessionFace | undefined {
|
||||
const id = scopeTagOf(ctx)
|
||||
if (id === undefined) return undefined
|
||||
return this.scopes.get(id)?.binding.session
|
||||
@@ -473,7 +372,7 @@ export class SessionsService {
|
||||
* return the static no-session projection rather than removing hook props.
|
||||
*/
|
||||
private maybeProvideInfo(id: string | undefined): SessionMaybeProvideInfo {
|
||||
return (id === undefined ? undefined : this.provideInfo(id)) ?? this.maybeInfo
|
||||
return (id === undefined ? undefined : this.provideInfo(id)) ?? this.provideChannel.maybeInfo
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -497,7 +396,7 @@ export class SessionsService {
|
||||
* validates and the projection masks absent selections), so resolve
|
||||
* cannot miss; kept so a future current writer cannot crash the notify. */
|
||||
if (record !== undefined) {
|
||||
void record.binding.session.open()
|
||||
void record.session.open()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -540,8 +439,9 @@ export class SessionsService {
|
||||
fiber,
|
||||
ctx,
|
||||
binding,
|
||||
session,
|
||||
// Sources are bare observables; React binds selector hooks at its own seam.
|
||||
provideInfo: this.materializeProvideInfo(binding),
|
||||
provideInfo: this.provideChannel.materializeInfo(binding),
|
||||
}
|
||||
this.scopes.set(id, record)
|
||||
return record
|
||||
@@ -608,7 +508,7 @@ export class SessionsService {
|
||||
void record.fiber.dispose()
|
||||
// Release the Session's dispatch point with the scope it belongs to (a
|
||||
// surviving instance — the live Intent — rebinds when resolve re-mints).
|
||||
record.binding.session.unbindScope()
|
||||
record.session.unbindScope()
|
||||
// Optional lookup: slots and sessions are sibling services with no
|
||||
// declared dependency; a slots-less boot (object-layer tests) skips.
|
||||
this.rootCtx.get('slots')?.pruneStoreScope(id)
|
||||
|
||||
@@ -10,7 +10,7 @@ import type {
|
||||
// Value import from the inline-safe wire layer (not the connection plugin):
|
||||
// plugin-to-plugin value imports are a bundle purity error.
|
||||
import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import type { ObservableSnapshot } from '../contract/store.ts'
|
||||
import type { SessionFace } from '../contract/session.ts'
|
||||
import type {
|
||||
CodeSubCall, ComposerPhase, ConversationNode, ConversationSnapshot, OpenState,
|
||||
PromptError, QueuedMessage, RunningToolCall,
|
||||
@@ -67,9 +67,11 @@ function queuePreviewOf(content: readonly ContentBlock[]): string {
|
||||
|
||||
/**
|
||||
* Owns a session's event window, derived conversation state, and observable
|
||||
* snapshot. React bindings remain outside this data layer.
|
||||
* snapshot. React bindings remain outside this data layer. Features see only
|
||||
* the {@link SessionFace} slice (ISession verbs + the snapshot source); the
|
||||
* remaining public members are manager/runtime entry points.
|
||||
*/
|
||||
export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
export class Session implements SessionFace {
|
||||
// ---- Window and derived state (all private; the snapshot is the only read surface) ----
|
||||
private events: SessionEvent[] = []
|
||||
/** Wire views aligned with `events` by index (envelope-level annotations; undefined = no view).
|
||||
|
||||
@@ -6,7 +6,8 @@ import type {
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SnapshotStore } from '../contract/store.ts'
|
||||
import { createSnapshotStore } from '../contract/store.ts'
|
||||
import type { SessionsService } from '../sessions/service.ts'
|
||||
import type { SessionsPort, SessionsPortList } from '../contract/sessions-port.ts'
|
||||
import type { IWorkspaces } from '../contract/workspaces.ts'
|
||||
import { WorkspaceManager, type WorkspaceListPhase } from './manager.ts'
|
||||
|
||||
/** Workspace list plus the two-baseline readiness and default-target projection. */
|
||||
@@ -30,7 +31,7 @@ export class WorkspaceCreateError extends Error {
|
||||
}
|
||||
|
||||
/** Real Workspace object layer and Host actions. */
|
||||
export class WorkspacesService {
|
||||
export class WorkspacesService implements IWorkspaces {
|
||||
/** UI-facing immutable projection; the manager remains wire truth. */
|
||||
readonly list: SnapshotStore<WorkspaceListState>
|
||||
/** Workspace baseline and frame owner. */
|
||||
@@ -43,9 +44,9 @@ export class WorkspacesService {
|
||||
/**
|
||||
* @param ctx - client root context.
|
||||
* @param api - shared wire client.
|
||||
* @param sessions - lower-level Session service used for recency and blank-session reuse.
|
||||
* @param sessions - cross-domain sessions face used for recency and blank-session reuse.
|
||||
*/
|
||||
constructor(ctx: Context, private readonly api: IApiClient, private readonly sessions: SessionsService) {
|
||||
constructor(ctx: Context, private readonly api: IApiClient, private readonly sessions: SessionsPort) {
|
||||
this.manager = new WorkspaceManager(api)
|
||||
this.list = createSnapshotStore<WorkspaceListState>({
|
||||
items: [], state: 'idle', phase: 'pending', error: null,
|
||||
@@ -271,7 +272,7 @@ export class WorkspacesService {
|
||||
/** Stable tie-breaking follows Host Workspace order. */
|
||||
function recentWorkspace(
|
||||
workspaces: readonly WorkspaceView[],
|
||||
sessions: ReturnType<SessionsService['list']['getSnapshot']>['byId'],
|
||||
sessions: SessionsPortList['byId'],
|
||||
): WorkspaceId | undefined {
|
||||
let selected: WorkspaceId | undefined
|
||||
let selectedTime = Number.NEGATIVE_INFINITY
|
||||
|
||||
@@ -160,6 +160,28 @@ describe('list lifecycle', () => {
|
||||
expect(manager.getListSnapshot().items.find(item => item.sessionId === S1)?.title).toBeUndefined()
|
||||
})
|
||||
|
||||
it('seeds cold titles from the list rows\' projections block under higher-seq-wins', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const manager = new SessionManager(api)
|
||||
// A push frame landed before the list (S2's title is newer than the block's cut).
|
||||
manager.handleMuxEnvelope({
|
||||
rpcId: 'push-newer' as never,
|
||||
payload: { type: 'session/projection', sessionId: S2, key: 'title', value: 'Pushed', seq: 9 } as never,
|
||||
})
|
||||
api.onList = () => Promise.resolve(ok({
|
||||
items: [
|
||||
{ ...summary(S1), projections: { asOfSeq: 4, values: { title: 'Cold cached' } } },
|
||||
{ ...summary(S2, { updatedAt: 200 }), projections: { asOfSeq: 5, values: { title: 'List stale' } } },
|
||||
] as never[],
|
||||
}))
|
||||
await manager.refreshList()
|
||||
const items = manager.getListSnapshot().items
|
||||
// Cold row: title surfaces straight from the list block — no open, no history.
|
||||
expect(items.find(item => item.sessionId === S1)?.title).toBe('Cold cached')
|
||||
// The stale list block (seq 5) cannot overwrite the newer push frame (seq 9).
|
||||
expect(items.find(item => item.sessionId === S2)?.title).toBe('Pushed')
|
||||
})
|
||||
|
||||
it('drops a projection row beyond the subscription baseline before accepting its durable replay', async () => {
|
||||
const api = new FakeApiClient()
|
||||
api.onList = () => Promise.resolve(ok({ items: [summary(S1)] as never[] }))
|
||||
|
||||
6
packages/client/test-runtime/README.i18n.yaml
Normal file
6
packages/client/test-runtime/README.i18n.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/test-runtime/README.md
|
||||
README.md: 883d71224139dc409229fdfb35362d040e810cc7
|
||||
README.zh.md: a3daf112940b03b585d44bc5fd1317e43ebd35fe
|
||||
24
packages/client/test-runtime/README.md
Normal file
24
packages/client/test-runtime/README.md
Normal file
@@ -0,0 +1,24 @@
|
||||
# @deepseek-ai/dsh-client-test-runtime
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
jsdom slot test runtime for client feature specs: a real Cordis `Context`, the production `SlotsService` and web-react renderer, assembled around typed session/workspace doubles. Feature suites exercise declaration, registration, scope, store, inject, rendering, updates, and disposal without hand-building the machinery per suite — and without a second implementation of any production logic.
|
||||
|
||||
The doubles implement the same outward faces features receive through ctx (`TestSessions implements ISessions`, `TestWorkspaces implements IWorkspaces`; each fixture session is a `FixtureSession implements SessionFace`), so a production face change breaks the bench at compile time instead of silently drifting. Provide-bundle materialization runs the production `SessionProvideChannel` — the one implementation shared with `SessionsService`. Fixtures feed plain data: list rows, conversation snapshots (immer-patched via `updateSnapshot`), projection values, and `ISession`-typed behavior stubs that fail loud when a spec calls an unstubbed verb. The typed `provide()` constrains fakes for declared service names to `Partial` of that service's outward face.
|
||||
|
||||
Local DOM snapshots: `declare(children)` registers an auto frame whose per-key `<div data-slot>` wrappers are snapshot roots; `renderSlot(key, owner)` returns the slot-local view (container, scoped Testing Library queries, in-place `update(owner)`); a registered snapshot serializer folds CSS-module class hashes (`_frame_a1b2c3` → `frame`) to keep `.snap` files structural and collapses `<svg>` internals to a `data-content` fingerprint. Suites needing a custom page frame use `root.declare(children, Frame)` instead; `mount(plugin)` runs a real fiber with fail-loud service prechecks, and `dispose()` tears down views, feature fibers, minted scopes, and persisted store state on one axis.
|
||||
|
||||
Not part of the product plugin graph (no `dshClient`); feature packages depend on it in `devDependencies` only.
|
||||
|
||||
## Model Experience
|
||||
|
||||
None, as this package is browser-side test infrastructure; nothing here reaches a model request.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
None; this package neither assembles nor sends a provider request.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Consumed through repository source aliases only.** Specs resolve the package through tsconfig `paths` to `src`; the built `lib/` artifact re-exports `@deepseek-ai/dsh-client-runtime/client`, whose bundle is a browser loader script with no Node ESM exports, so `lib/index.js` is not importable under plain Node. Acceptable while every consumer is an in-repo Vitest suite; a Node-compatible runtime entry is deferred until an out-of-repo consumer exists.
|
||||
- **Conversation snapshots are fixture data, not replayed history.** `updateSnapshot` writes the snapshot store directly; the wire-to-snapshot computation stays covered by the runtime package's own tests and the replay e2e. A fixture can therefore express states the production fold would never produce.
|
||||
24
packages/client/test-runtime/README.zh.md
Normal file
24
packages/client/test-runtime/README.zh.md
Normal file
@@ -0,0 +1,24 @@
|
||||
# @deepseek-ai/dsh-client-test-runtime
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
面向 client feature 测试的 jsdom slot 测试运行时:真实 Cordis `Context`、生产 `SlotsService` 与 web-react 渲染器,围绕带类型的 session/workspace 测试替身组装。feature 套件无需逐套件手搭机器即可测遍声明、注册、scope、store、inject、渲染、更新与销毁——且不存在任何生产逻辑的第二份实现。
|
||||
|
||||
替身实现的正是 feature 经 ctx 拿到的对外面(`TestSessions implements ISessions`、`TestWorkspaces implements IWorkspaces`;每个 fixture session 是 `FixtureSession implements SessionFace`),生产面一旦改形,测试台在编译期即断,而非静默漂移。provide bundle 材料化直接运行生产 `SessionProvideChannel`——与 `SessionsService` 共用同一份实现。fixture 灌入的是普通数据:列表行、会话快照(经 `updateSnapshot` 以 immer 补丁改写)、projection 值,以及按 `ISession` 取型的行为桩——spec 调用未打桩的动词时报错自明。带类型的 `provide()` 将已声明服务名的 fake 约束为该服务对外面的 `Partial` 子集。
|
||||
|
||||
局部 DOM 快照:`declare(children)` 注册自动 frame,逐 key 的 `<div data-slot>` 包裹层即快照根;`renderSlot(key, owner)` 返回该 slot 的局部视图(container、限定范围的 Testing Library 查询、原位 `update(owner)`);注册的快照序列化器把 CSS-module 哈希类名折回语义名(`_frame_a1b2c3` → `frame`)保持 `.snap` 只含结构,并把 `<svg>` 内部折叠为 `data-content` 指纹。需要自定义页面 frame 的套件改用 `root.declare(children, Frame)`;`mount(plugin)` 在真实 fiber 上运行并对缺失服务先行报错;`dispose()` 沿单一轴拆除视图、feature fiber、已铸 scope 与持久化 store 状态。
|
||||
|
||||
不属于产品插件图(无 `dshClient`);feature 包仅以 `devDependencies` 依赖之。
|
||||
|
||||
## Model Experience
|
||||
|
||||
无;本包是浏览器侧测试基础设施,无一物到达模型请求。
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
无;本包既不组装也不发送 provider 请求。
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **仅可经仓内源码别名消费。** spec 通过 tsconfig `paths` 解析到 `src`;构建产物 `lib/` 再导出 `@deepseek-ai/dsh-client-runtime/client`,而该 bundle 是无 Node ESM 导出的浏览器 loader 脚本,故 `lib/index.js` 在纯 Node 下不可导入。当前所有消费方都是仓内 Vitest 套件,可接受;Node 兼容的运行时入口待出现仓外消费方再补。
|
||||
- **会话快照是 fixture 数据,不是重放历史。** `updateSnapshot` 直写快照 store;wire 到快照的运算仍由 runtime 包自身测试与 replay e2e 把守。因此 fixture 可以表达生产折叠永不产出的状态。
|
||||
54
packages/client/test-runtime/package.json
Normal file
54
packages/client/test-runtime/package.json
Normal file
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-test-runtime",
|
||||
"description": "jsdom slot test runtime: real Cordis Context + SlotsService + web-react renderer with test-owned session/workspace doubles for feature specs",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"vitest": "^4.1.8"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-web-react": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-web-react": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@types/react": "~18.3.1",
|
||||
"@types/react-dom": "~18.3.0",
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
]
|
||||
}
|
||||
82
packages/client/test-runtime/src/fixtures.ts
Normal file
82
packages/client/test-runtime/src/fixtures.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
/** Session/workspace fixture shapes and snapshot defaults for the test runtime. */
|
||||
import type {
|
||||
ConversationSnapshot, ISession, SessionId, SessionSummary, WorkspaceListState,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
|
||||
/**
|
||||
* Fixture overrides for the session behavior face: any subset of the
|
||||
* production ISession verbs (typed against it, so a face change surfaces
|
||||
* here at compile time), plus extra members feature-specific casts consume.
|
||||
* The open Record tail means a misnamed EXTRA member is not caught by the
|
||||
* compiler (it grafts as dead weight); the ISession verbs stay safe — a
|
||||
* misnamed verb leaves the fail-loud stub in place, which names itself at
|
||||
* the first call.
|
||||
*/
|
||||
export type SessionBehaviorOverrides = Partial<ISession> & Record<string, unknown>
|
||||
|
||||
/**
|
||||
* act-wrapped mutation runner shared by every runtime object: public mutators
|
||||
* funnel through it so tests never handle SlotCore microtask batching or
|
||||
* React act themselves.
|
||||
*/
|
||||
export type Stabilizer = (fn: () => void | Promise<void>) => Promise<void>
|
||||
|
||||
/**
|
||||
* Session fixture accepted by {@link TestSessions.add}: identity plus optional
|
||||
* snapshot/list-row overrides and the session behavior face the feature under
|
||||
* test actually calls (kept open — the runtime never fakes methods a test did
|
||||
* not supply, so an unstubbed call fails loud at the call site).
|
||||
*/
|
||||
export interface SessionFixture {
|
||||
id: string
|
||||
/** Overrides merged over {@link conversationSnapshot} (sessionId comes from `id`). */
|
||||
snapshot?: Partial<Omit<ConversationSnapshot, 'sessionId'>>
|
||||
/** List-row overrides merged over the defaults derived from `id`. */
|
||||
summary?: Partial<Omit<SessionSummary, 'id'>>
|
||||
/** Session behavior face: exactly the methods the feature under test calls (ISession subset + extras). */
|
||||
session?: SessionBehaviorOverrides
|
||||
}
|
||||
|
||||
/**
|
||||
* A complete quiescent conversation snapshot (open window, no traffic).
|
||||
* @param sessionId - owning session id.
|
||||
* @returns the snapshot; spread fixture overrides on top.
|
||||
*/
|
||||
export function conversationSnapshot(sessionId: SessionId): ConversationSnapshot {
|
||||
return {
|
||||
sessionId,
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A ready workspace list with no workspaces (the shape WorkspacesService
|
||||
* projects after both baselines land).
|
||||
* @returns the initial state of the test workspaces store.
|
||||
*/
|
||||
export function workspaceListState(): WorkspaceListState {
|
||||
return {
|
||||
items: [],
|
||||
state: 'idle',
|
||||
phase: 'ready',
|
||||
error: null,
|
||||
baselinesReady: true,
|
||||
recentWorkspaceId: undefined,
|
||||
}
|
||||
}
|
||||
372
packages/client/test-runtime/src/index.ts
Normal file
372
packages/client/test-runtime/src/index.ts
Normal file
@@ -0,0 +1,372 @@
|
||||
/**
|
||||
* jsdom slot test runtime: a real small runtime — Cordis `Context`, the
|
||||
* runtime `SlotsService`, and the web-react renderer — assembled around
|
||||
* test-owned session/workspace doubles, so feature specs exercise
|
||||
* declaration, registration, scope, store, inject, rendering, updates, and
|
||||
* disposal without hand-building the machinery per suite.
|
||||
*
|
||||
* Not part of the product plugin graph (no `dshClient`); feature packages
|
||||
* depend on it in devDependencies only. It copies no SlotCore/renderer/store
|
||||
* machinery — everything mounts the production implementations.
|
||||
* @module @deepseek-ai/dsh-client-test-runtime
|
||||
*/
|
||||
/* eslint-disable @typescript-eslint/no-redundant-type-constituents --
|
||||
* `keyof SlotMap & string` is the declare-merge key pattern (see ui-slots):
|
||||
* this compilation unit sees only the runtime's 'root' row, but consumer
|
||||
* programs merge their own keys in; the rule fires on the narrow-map view. */
|
||||
import { Context, Inject } from 'cordis'
|
||||
import type { Fiber, Plugin } from 'cordis'
|
||||
import { createElement, Fragment, useSyncExternalStore } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { act, render, within } from '@testing-library/react'
|
||||
import type { RenderResult } from '@testing-library/react'
|
||||
import type { queries } from '@testing-library/dom'
|
||||
import type { BoundFunctions } from '@testing-library/dom'
|
||||
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { createSlotRenderer } from '@deepseek-ai/dsh-client-web-react'
|
||||
import type {
|
||||
ChildrenDecl, ComposedProps, OwnerOf, SlotComponent, SlotMap, SlotRendererHost, StoreInstanceLike,
|
||||
} from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { registerDomSnapshotSerializer } from './snapshot.ts'
|
||||
import { TestSessions } from './sessions.ts'
|
||||
import { TestWorkspaces } from './workspaces.ts'
|
||||
import type { Stabilizer } from './fixtures.ts'
|
||||
|
||||
export { domSnapshotSerializer, registerDomSnapshotSerializer } from './snapshot.ts'
|
||||
export { FixtureSession, TestSessions } from './sessions.ts'
|
||||
export { TestWorkspaces } from './workspaces.ts'
|
||||
export { conversationSnapshot, workspaceListState } from './fixtures.ts'
|
||||
export type { SessionBehaviorOverrides, SessionFixture, Stabilizer } from './fixtures.ts'
|
||||
|
||||
/** Erased register face for the internal root call (the public declare seam holds the typing). */
|
||||
type ErasedRegister = (options: object, component: unknown) => () => void
|
||||
|
||||
/**
|
||||
* One rendered slot's local view, from {@link SlotTestRuntime.renderSlot}:
|
||||
* the `data-slot` wrapper is the snapshot root (`expect(view.container)
|
||||
* .toMatchSnapshot()` captures exactly this slot's output), Testing Library
|
||||
* queries are bound inside it, and `update` re-renders with new owner props.
|
||||
*/
|
||||
export interface SlotView<K extends keyof SlotMap & string> {
|
||||
/** The `<div data-slot="<key>">` wrapper around the slot's rendered output. */
|
||||
readonly container: HTMLElement
|
||||
/** Testing Library queries scoped to {@link SlotView.container}. */
|
||||
readonly view: BoundFunctions<typeof queries>
|
||||
/**
|
||||
* Replace the owner props and flush the re-render (the render-site update:
|
||||
* in production the owner recomputes the share and React re-renders).
|
||||
* @param owner - the next owner props share.
|
||||
*/
|
||||
update(owner: OwnerOf<K>): void
|
||||
}
|
||||
|
||||
/**
|
||||
* Mounted feature plugin handle: the live fiber plus an act-wrapped,
|
||||
* idempotent dispose (unload cascade: entries, declared child slots, store
|
||||
* instances, and provided services all fall together).
|
||||
*/
|
||||
export interface FeatureHandle {
|
||||
/** The plugin's live Cordis fiber (state assertions, escape hatch). */
|
||||
readonly fiber: Fiber
|
||||
/**
|
||||
* Dispose the plugin fiber inside React act; repeated calls no-op.
|
||||
* @returns completion of the unload cascade.
|
||||
*/
|
||||
dispose(): Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Owner-props cell behind the auto frame: one external store the frame
|
||||
* subscribes to, so {@link SlotTestRuntime.renderSlot} and
|
||||
* {@link SlotView.update} drive React through the standard uSES seam.
|
||||
*/
|
||||
class OwnerPropsCell {
|
||||
private readonly owners = new Map<string, object>()
|
||||
private readonly listeners = new Set<() => void>()
|
||||
private version = 0
|
||||
|
||||
/** Snapshot version for uSES pairing (bumped on every set). */
|
||||
readonly getVersion = (): number => this.version
|
||||
|
||||
/**
|
||||
* Subscribe to owner-props changes.
|
||||
* @param fn - change callback.
|
||||
* @returns unsubscribe.
|
||||
*/
|
||||
readonly subscribe = (fn: () => void): (() => void) => {
|
||||
this.listeners.add(fn)
|
||||
return () => { this.listeners.delete(fn) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Install or replace one key's owner props and notify (synchronous; the
|
||||
* caller wraps in act).
|
||||
* @param key - slot key.
|
||||
* @param owner - owner props share.
|
||||
*/
|
||||
set(key: string, owner: object): void {
|
||||
this.owners.set(key, owner)
|
||||
this.version += 1
|
||||
for (const fn of [...this.listeners]) fn()
|
||||
}
|
||||
|
||||
/** Keys with supplied owner props, in first-supply order. */
|
||||
entries(): readonly (readonly [string, object])[] {
|
||||
return [...this.owners.entries()]
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The test-owned 'root' occupant: declares the child slots a suite needs
|
||||
* through the REAL `slots.register`, with a caller-supplied minimal frame —
|
||||
* the runtime never guesses a feature's page structure.
|
||||
*/
|
||||
export class TestRoot {
|
||||
private disposeEntry: (() => void) | undefined
|
||||
|
||||
/**
|
||||
* @param slots - the runtime SlotsService.
|
||||
* @param stabilize - the owning runtime's act wrapper.
|
||||
*/
|
||||
constructor(private readonly slots: SlotsService, private readonly stabilize: Stabilizer) {}
|
||||
|
||||
/**
|
||||
* Register the root frame, declaring (and thereby claiming) the child
|
||||
* slots. One declaration per runtime — a second call fails loud in the
|
||||
* core ('root' is a single slot).
|
||||
* @param children - child-slot declaration table (declaration + render authorization + runtime spec).
|
||||
* @param frame - minimal frame component; its props derive from the declared keys (composed-props contract).
|
||||
* @returns completion of the act-wrapped registration.
|
||||
*/
|
||||
async declare<const D extends ChildrenDecl>(
|
||||
children: D,
|
||||
frame: SlotComponent<ComposedProps<'root', keyof NoInfer<D> & keyof SlotMap & string, undefined, object>>,
|
||||
): Promise<void> {
|
||||
await this.stabilize(() => {
|
||||
// Erased hop (same pattern as SlotsService's own implementation arm);
|
||||
// the declare signature above is the typed seam.
|
||||
this.disposeEntry = (this.slots.register as unknown as ErasedRegister)({ name: 'root', children }, frame)
|
||||
})
|
||||
}
|
||||
|
||||
/** Remove the root registration and collapse its declarations (runtime dispose path). */
|
||||
release(): void {
|
||||
this.disposeEntry?.()
|
||||
this.disposeEntry = undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The assembled test runtime. Obtain via {@link SlotTestRuntime.create};
|
||||
* dispose with {@link SlotTestRuntime.dispose} (afterEach). Public mutators
|
||||
* are act-wrapped throughout — tests never handle SlotCore microtask
|
||||
* batching or React act themselves.
|
||||
*/
|
||||
export class SlotTestRuntime {
|
||||
/** The runtime's Cordis root (escape hatch: extra services via `ctx.provide`, raw `ctx.plugin` mounts). */
|
||||
readonly ctx: Context
|
||||
/** The production SlotsService mounted on {@link SlotTestRuntime.ctx}. */
|
||||
readonly slots: SlotsService
|
||||
/** The test-owned 'root' occupant. */
|
||||
readonly root: TestRoot
|
||||
/** Sessions double (list/current observable, cells, scopes, behavior faces). */
|
||||
readonly sessions: TestSessions
|
||||
/** Workspaces double (list observable, recorded intent actions). */
|
||||
readonly workspaces: TestWorkspaces
|
||||
|
||||
private readonly stabilizer: Stabilizer = async (fn) => {
|
||||
await act(async () => { await fn() })
|
||||
}
|
||||
|
||||
private host: SlotRendererHost | undefined
|
||||
private readonly views: RenderResult[] = []
|
||||
private readonly handles: FeatureHandle[] = []
|
||||
private disposed = false
|
||||
/** Auto-frame state ({@link SlotTestRuntime.declare} / {@link SlotTestRuntime.renderSlot}). */
|
||||
private readonly ownerCell = new OwnerPropsCell()
|
||||
private readonly autoDeclared = new Set<string>()
|
||||
private autoRootView: RenderResult | undefined
|
||||
|
||||
private constructor(ctx: Context, slots: SlotsService) {
|
||||
this.ctx = ctx
|
||||
this.slots = slots
|
||||
this.root = new TestRoot(slots, this.stabilizer)
|
||||
this.sessions = new TestSessions(this.stabilizer, ctx)
|
||||
this.workspaces = new TestWorkspaces(this.stabilizer)
|
||||
ctx.provide('sessions', this.sessions)
|
||||
ctx.provide('workspaces', this.workspaces)
|
||||
// Capturing install: the production renderer does the rendering; the
|
||||
// wrapper only takes the host face for storeOf (no machinery copied).
|
||||
const renderer = createSlotRenderer()
|
||||
slots.install({
|
||||
renderRoot: (host, ownerProps) => {
|
||||
this.host = host
|
||||
return renderer.renderRoot(host, ownerProps)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Assemble a runtime: real Context, mounted SlotsService, installed
|
||||
* renderer, and the session/workspace doubles provided as services.
|
||||
* @returns the ready runtime.
|
||||
*/
|
||||
static async create(): Promise<SlotTestRuntime> {
|
||||
registerDomSnapshotSerializer()
|
||||
const ctx = new Context()
|
||||
const fiber = ctx.plugin(SlotsService)
|
||||
await fiber.await()
|
||||
return new SlotTestRuntime(ctx, ctx.get('slots') as SlotsService)
|
||||
}
|
||||
|
||||
/**
|
||||
* Provide an extra service the feature under test injects (e.g. a layout
|
||||
* fake). Sugar over `ctx.provide`, typed against the Context declaration
|
||||
* merge: for a declared service name the fake must be a subset of that
|
||||
* service's outward face (Partial — supply only what the feature calls),
|
||||
* so a production face change breaks the fake at compile time. Undeclared
|
||||
* names stay unchecked (ad-hoc test services).
|
||||
* @param name - service name.
|
||||
* @param value - service implementation (test double).
|
||||
*/
|
||||
provide<K extends string>(name: K, value: K extends keyof Context ? Partial<Context[K]> : unknown): void {
|
||||
this.ctx.provide(name, value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount a feature plugin on a real fiber. Required services are prechecked
|
||||
* so a missing provider fails loud instead of suspending the fiber forever
|
||||
* (deliberate load-order suspension tests use `ctx.plugin` directly).
|
||||
* @param plugin - plugin value (function, class, or `{ inject, apply }` object).
|
||||
* @returns handle owning the fiber's explicit disposal.
|
||||
*/
|
||||
async mount(plugin: Plugin): Promise<FeatureHandle> {
|
||||
const required = Object.keys(Inject.resolve((plugin as { inject?: Inject }).inject))
|
||||
const missing = required.filter(name => this.ctx.get(name) === undefined)
|
||||
if (missing.length > 0) {
|
||||
throw new Error(`mount would suspend: missing service(s) ${missing.join(', ')} — provide() them first`)
|
||||
}
|
||||
const fiber = this.ctx.plugin(plugin)
|
||||
await this.stabilizer(async () => {
|
||||
await fiber.await()
|
||||
})
|
||||
let disposed = false
|
||||
const handle: FeatureHandle = {
|
||||
fiber,
|
||||
dispose: async () => {
|
||||
if (disposed) return
|
||||
disposed = true
|
||||
await this.stabilizer(() => fiber.dispose())
|
||||
},
|
||||
}
|
||||
this.handles.push(handle)
|
||||
return handle
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the root slot tree through the ctx-level entry (the shell's own
|
||||
* seam): `ctx.slots.renderSlot('root', {})` under Testing Library.
|
||||
* @returns the Testing Library view.
|
||||
*/
|
||||
renderRoot(): RenderResult {
|
||||
const view = render(createElement(Fragment, null, this.slots.renderSlot('root', {})))
|
||||
this.views.push(view)
|
||||
return view
|
||||
}
|
||||
|
||||
/**
|
||||
* Declare child slots under an auto-generated root frame — the single-slot
|
||||
* mounting path for local DOM snapshots. Each key later supplied through
|
||||
* {@link SlotTestRuntime.renderSlot} renders inside its own
|
||||
* `<div data-slot="<key>">` wrapper (the snapshot root). Mutually exclusive
|
||||
* with {@link TestRoot.declare} ('root' is a single slot); one call per
|
||||
* runtime.
|
||||
* @param children - child-slot declaration table (same contract as TestRoot.declare).
|
||||
* @returns completion of the act-wrapped registration.
|
||||
*/
|
||||
async declare(children: ChildrenDecl): Promise<void> {
|
||||
for (const key of Object.keys(children)) this.autoDeclared.add(key)
|
||||
const cell = this.ownerCell
|
||||
const AutoFrame = (props: { renderSlot: (key: string, owner: object) => ReactNode }) => {
|
||||
useSyncExternalStore(cell.subscribe, cell.getVersion)
|
||||
return createElement(Fragment, null, cell.entries().map(([key, owner]) =>
|
||||
createElement('div', { 'data-slot': key, key }, props.renderSlot(key, owner))))
|
||||
}
|
||||
await this.root.declare(children as never, AutoFrame as never)
|
||||
}
|
||||
|
||||
/**
|
||||
* Render one declared slot with its owner props and return the local view.
|
||||
* The whole root tree mounts through the production assembly path
|
||||
* (renderer, scope providers, store axis); only this key's output lands in
|
||||
* the returned container. Call again with another key to view a sibling
|
||||
* slot of the same tree.
|
||||
* @param key - a key declared through {@link SlotTestRuntime.declare}.
|
||||
* @param owner - owner props share for the render site.
|
||||
* @returns the slot-local view (snapshot container, scoped queries, owner updates).
|
||||
*/
|
||||
renderSlot<K extends keyof SlotMap & string>(key: K, owner: OwnerOf<K>): SlotView<K> {
|
||||
if (!this.autoDeclared.has(key)) {
|
||||
throw new Error(`renderSlot('${key}') without declare() — declare the key first (or use root.declare for a custom frame)`)
|
||||
}
|
||||
const install = (next: object): void => {
|
||||
// Synchronous cell write inside act: the frame re-renders through uSES.
|
||||
act(() => {
|
||||
this.ownerCell.set(key, next)
|
||||
})
|
||||
}
|
||||
install(owner)
|
||||
this.autoRootView ??= this.renderRoot()
|
||||
const container = this.autoRootView.container.querySelector(`[data-slot="${key}"]`)
|
||||
if (!(container instanceof HTMLElement)) {
|
||||
throw new Error(`renderSlot('${key}'): the auto frame rendered no wrapper — was the runtime already disposed?`)
|
||||
}
|
||||
return { container, view: within(container), update: install }
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the store instance the renderer would hand a slot's component
|
||||
* (identity assertions, action-driven writes). Requires a prior
|
||||
* {@link SlotTestRuntime.renderRoot} — the host face exists only inside the
|
||||
* installed renderer, exactly as in production.
|
||||
* @param key - slot key whose first entry declares the store.
|
||||
* @param scopeKey - session id for session-scope slots; omit for root scope.
|
||||
* @returns the live store instance.
|
||||
*/
|
||||
storeOf(key: keyof SlotMap & string, scopeKey?: string): StoreInstanceLike {
|
||||
if (this.host === undefined) {
|
||||
throw new Error('storeOf before renderRoot() — the host face exists only inside the installed renderer')
|
||||
}
|
||||
const entry = this.host.entriesOf(key)[0]
|
||||
if (entry === undefined) throw new Error(`storeOf('${key}'): no registration on the ledger`)
|
||||
const instance = this.host.storeOf(entry, scopeKey)
|
||||
if (instance === undefined) throw new Error(`storeOf('${key}'): the entry declares no store`)
|
||||
return instance
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush pending ledger/store notifications inside act — for mutations made
|
||||
* outside the runtime's own methods (e.g. a direct `slots.register`).
|
||||
* @returns completion of the act pass.
|
||||
*/
|
||||
async flush(): Promise<void> {
|
||||
await this.stabilizer(() => {})
|
||||
}
|
||||
|
||||
/**
|
||||
* Tear down: unmount React trees first, then dispose feature fibers, the
|
||||
* root registration, minted session scopes, and persisted test state.
|
||||
* Idempotent.
|
||||
* @returns completion of the teardown.
|
||||
*/
|
||||
async dispose(): Promise<void> {
|
||||
if (this.disposed) return
|
||||
this.disposed = true
|
||||
this.autoRootView = undefined
|
||||
for (const view of this.views.splice(0)) view.unmount()
|
||||
for (const handle of this.handles.splice(0)) await handle.dispose()
|
||||
this.root.release()
|
||||
await this.sessions.disposeScopes()
|
||||
localStorage.clear()
|
||||
}
|
||||
}
|
||||
32
packages/client/test-runtime/src/invariant.ts
Normal file
32
packages/client/test-runtime/src/invariant.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-client-test-runtime`.
|
||||
* @module @deepseek-ai/dsh-client-test-runtime/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-client-test-runtime'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'client-test-runtime-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: this test-support package owns no production event
|
||||
* stream or mutable data — it assembles the runtime SlotsService and renderer
|
||||
* (whose packages own their invariants) around test doubles; its own behavior
|
||||
* is exercised by its package tests.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
390
packages/client/test-runtime/src/sessions.ts
Normal file
390
packages/client/test-runtime/src/sessions.ts
Normal file
@@ -0,0 +1,390 @@
|
||||
/** Test-owned sessions face: the SlotsService host contract over declarative fixtures. */
|
||||
import type { Context } from 'cordis'
|
||||
import { createScope, scopeOf, SessionProvideChannel } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {
|
||||
ConversationSnapshot, ISessions, ObservableSnapshot, ProjectionsFace, SessionFace, SessionId,
|
||||
SessionListState, SessionProvideDescriptor, SessionSummary, SnapshotStore,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { HostObservable, SessionMaybeProvideInfo, SessionProvideInfo } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { conversationSnapshot } from './fixtures.ts'
|
||||
import type { SessionFixture, Stabilizer } from './fixtures.ts'
|
||||
|
||||
/**
|
||||
* The fixture-backed session face: conversation reads delegate to the
|
||||
* fixture's snapshot store; ISession verbs are fail-loud stubs unless the
|
||||
* fixture supplies them (the runtime never fakes behavior a test did not
|
||||
* declare — an unstubbed call names itself instead of half-working). Extra
|
||||
* fixture methods are grafted verbatim for feature-side casts.
|
||||
*/
|
||||
export class FixtureSession implements SessionFace {
|
||||
/**
|
||||
* The useProjection seat: identity-stable per-key faces over the fixture's
|
||||
* projection values (set via {@link TestSessions.setProjection}).
|
||||
*/
|
||||
readonly projections: ProjectionsFace & { set(key: string, value: unknown): void }
|
||||
|
||||
/**
|
||||
* @param sessionId - host identity (branded view of the fixture id).
|
||||
* @param store - conversation snapshot store (updateSnapshot writes it).
|
||||
* @param overrides - fixture-declared behavior face, grafted over the stubs.
|
||||
*/
|
||||
constructor(
|
||||
readonly sessionId: SessionId,
|
||||
private readonly store: SnapshotStore<ConversationSnapshot>,
|
||||
overrides: Record<string, unknown>,
|
||||
) {
|
||||
const values = new Map<string, unknown>()
|
||||
const listeners = new Map<string, Set<() => void>>()
|
||||
const faces = new Map<string, ObservableSnapshot<unknown>>()
|
||||
this.projections = {
|
||||
faceOf: (key: string) => {
|
||||
let face = faces.get(key)
|
||||
if (face === undefined) {
|
||||
face = {
|
||||
getSnapshot: () => values.get(key),
|
||||
subscribe: (fn: () => void) => {
|
||||
const set = listeners.get(key) ?? new Set()
|
||||
set.add(fn)
|
||||
listeners.set(key, set)
|
||||
return () => { set.delete(fn) }
|
||||
},
|
||||
}
|
||||
faces.set(key, face)
|
||||
}
|
||||
return face
|
||||
},
|
||||
set: (key: string, value: unknown) => {
|
||||
values.set(key, value)
|
||||
for (const fn of [...(listeners.get(key) ?? [])]) fn()
|
||||
},
|
||||
}
|
||||
Object.assign(this, overrides)
|
||||
}
|
||||
|
||||
/** @returns the fixture conversation snapshot (useSession read side). */
|
||||
getSnapshot(): ConversationSnapshot {
|
||||
return this.store.getSnapshot()
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to fixture snapshot changes.
|
||||
* @param fn - change callback.
|
||||
* @returns unsubscribe.
|
||||
*/
|
||||
subscribe(fn: () => void): () => void {
|
||||
return this.store.subscribe(fn)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fail-loud stub; supply `prompt` on the fixture's session face to exercise it.
|
||||
* @returns never — always throws.
|
||||
*/
|
||||
prompt(): never {
|
||||
throw new Error(`test session "${this.sessionId}": prompt is not stubbed — supply it on the fixture's session face`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fail-loud stub; supply `cancel` on the fixture's session face to exercise it.
|
||||
* @returns never — always throws.
|
||||
*/
|
||||
cancel(): never {
|
||||
throw new Error(`test session "${this.sessionId}": cancel is not stubbed — supply it on the fixture's session face`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fail-loud stub; supply `loadOlder` on the fixture's session face to exercise it.
|
||||
* @returns never — always throws.
|
||||
*/
|
||||
loadOlder(): never {
|
||||
throw new Error(`test session "${this.sessionId}": loadOlder is not stubbed — supply it on the fixture's session face`)
|
||||
}
|
||||
}
|
||||
|
||||
/** One live test session: fixture-derived stores plus its minted scope state. */
|
||||
interface SessionRecord {
|
||||
summary: SessionSummary
|
||||
snapshot: SnapshotStore<ConversationSnapshot>
|
||||
session: FixtureSession
|
||||
scope: Context | undefined
|
||||
scopeFiber: { dispose(): Promise<void> } | undefined
|
||||
/** Materialized standard-props bundle (identity-stable per session; invalidated on roster change). */
|
||||
provideInfo: SessionProvideInfo | undefined
|
||||
}
|
||||
|
||||
/** Test binding shape handed to provider resolvers and feature injects (a SessionBinding whose session is the fixture face). */
|
||||
export interface TestSessionBinding {
|
||||
readonly sessionId: SessionId
|
||||
readonly session: FixtureSession
|
||||
readonly ctx: Context
|
||||
}
|
||||
|
||||
/**
|
||||
* Sessions test double behind the renderer host and feature injects: owns the
|
||||
* list/current observable, the standard-props provide channel (the runtime's
|
||||
* `useSession` contribution included), scope minting through the production
|
||||
* `createScope`, and the session behavior face supplied per fixture.
|
||||
*
|
||||
* Implements the same ISessions face features receive as `ctx.sessions`, so
|
||||
* a production face change breaks this double at compile time; the extra
|
||||
* members (add/updateSnapshot/setCurrent/remove/behavior/calls and the
|
||||
* legacy provideInfo/maybeProvideInfo lookups) are bench-only surface.
|
||||
*/
|
||||
export class TestSessions implements ISessions {
|
||||
/** The useSessions standard feed (list rows + current selection). */
|
||||
readonly list: SnapshotStore<SessionListState>
|
||||
/**
|
||||
* Atomic current-session provide projection (production SessionsService
|
||||
* mirror): selection changes and provider-roster changes publish through
|
||||
* this one source — the member the SlotsService host face hands the
|
||||
* renderer's SessionProvider.
|
||||
*/
|
||||
readonly currentProvideInfo: HostObservable<SessionMaybeProvideInfo>
|
||||
private readonly records = new Map<SessionId, SessionRecord>()
|
||||
/** The production provide channel (roster, materialization rules, current projection) — no test-side mirror. */
|
||||
private readonly channel: SessionProvideChannel
|
||||
|
||||
/** Calls observed on the service-level face (open/clear), newest last. */
|
||||
readonly calls: { method: 'open' | 'clear'; args: unknown[] }[] = []
|
||||
|
||||
/**
|
||||
* @param stabilize - the owning runtime's act wrapper.
|
||||
* @param rootCtx - the runtime's Cordis root; scope fibers mount under it.
|
||||
*/
|
||||
constructor(private readonly stabilize: Stabilizer, private readonly rootCtx: Context) {
|
||||
this.list = createSnapshotStore<SessionListState>({
|
||||
ids: [], byId: {}, current: undefined, phase: 'ready',
|
||||
})
|
||||
this.channel = new SessionProvideChannel({
|
||||
rebuildBundles: () => {
|
||||
for (const record of this.records.values()) {
|
||||
if (record.provideInfo !== undefined) {
|
||||
record.provideInfo = this.channel.materializeInfo(this.bindingOf(record.session.sessionId, record))
|
||||
}
|
||||
}
|
||||
},
|
||||
resolveCurrent: () => this.maybeProvideInfo(this.list.getSnapshot().current),
|
||||
})
|
||||
this.currentProvideInfo = this.channel.currentProvideInfo
|
||||
// The projection follows every current write, as in production.
|
||||
this.list.subscribe(() => { this.channel.publishCurrent() })
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a session from a fixture and (by default) make it current.
|
||||
* @param fixture - identity + snapshot/summary overrides + behavior face.
|
||||
* @param opts - pass `current: false` to add without selecting.
|
||||
* @returns the stable session id (branded view of `fixture.id`).
|
||||
*/
|
||||
async add(fixture: SessionFixture, opts?: { current?: boolean }): Promise<SessionId> {
|
||||
const id = fixture.id as SessionId
|
||||
if (this.records.has(id)) throw new Error(`test session "${id}" already added`)
|
||||
const summary: SessionSummary = {
|
||||
id,
|
||||
displayTitle: fixture.id,
|
||||
running: false,
|
||||
blank: false,
|
||||
updatedAt: this.records.size + 1,
|
||||
...fixture.summary,
|
||||
}
|
||||
const snapshot = createSnapshotStore<ConversationSnapshot>({
|
||||
...conversationSnapshot(id),
|
||||
...fixture.snapshot,
|
||||
})
|
||||
this.records.set(id, {
|
||||
summary,
|
||||
snapshot,
|
||||
session: new FixtureSession(id, snapshot, fixture.session ?? {}),
|
||||
scope: undefined,
|
||||
scopeFiber: undefined,
|
||||
provideInfo: undefined,
|
||||
})
|
||||
await this.stabilize(() => {
|
||||
this.list.update((draft) => {
|
||||
draft.ids.push(id)
|
||||
draft.byId[id] = summary
|
||||
if (opts?.current !== false) draft.current = id
|
||||
})
|
||||
})
|
||||
return id
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a session's conversation snapshot through an immer draft (the
|
||||
* live-stream stand-in: components subscribed via useSession re-render).
|
||||
* @param id - session id.
|
||||
* @param mutate - draft mutator.
|
||||
*/
|
||||
async updateSnapshot(id: string, mutate: (draft: ConversationSnapshot) => void): Promise<void> {
|
||||
const record = this.require(id)
|
||||
await this.stabilize(() => { record.snapshot.update(mutate) })
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch the current selection (undefined = the no-session empty state).
|
||||
* @param id - session id to select, or undefined to clear.
|
||||
*/
|
||||
async setCurrent(id: string | undefined): Promise<void> {
|
||||
if (id !== undefined) this.require(id)
|
||||
await this.stabilize(() => {
|
||||
this.list.update((draft) => { draft.current = id as SessionId | undefined })
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a session: list row, scope fiber, and per-session store instances
|
||||
* (with persisted state) die together — the same single lifecycle axis the
|
||||
* production SessionsService drives on session death, minus staging.
|
||||
* @param id - session id.
|
||||
*/
|
||||
async remove(id: string): Promise<void> {
|
||||
const record = this.require(id)
|
||||
this.records.delete(id as SessionId)
|
||||
await this.stabilize(async () => {
|
||||
this.list.update((draft) => {
|
||||
draft.ids = draft.ids.filter(existing => existing !== id)
|
||||
const { [id as SessionId]: _dead, ...rest } = draft.byId
|
||||
draft.byId = rest
|
||||
if (draft.current === id) draft.current = undefined
|
||||
})
|
||||
if (record.scopeFiber !== undefined) await record.scopeFiber.dispose()
|
||||
this.rootCtx.get('slots')?.pruneStoreScope(id)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a per-session standard-props provider (production `provide`
|
||||
* contract: hooks become `use<Name>` selector hooks on the render side,
|
||||
* props spread verbatim; duplicate names fail loud at materialization).
|
||||
* @param descriptor - static member roster plus per-session resolver.
|
||||
* @returns disposer removing the provider.
|
||||
*/
|
||||
provide(descriptor: SessionProvideDescriptor): () => void {
|
||||
return this.channel.provide(descriptor)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the definite per-session standard-props bundle (host face member).
|
||||
* @param id - session id.
|
||||
* @returns the identity-stable bundle, or undefined for unknown sessions.
|
||||
*/
|
||||
provideInfo(id: string): SessionProvideInfo | undefined {
|
||||
const record = this.records.get(id as SessionId)
|
||||
if (record === undefined) return undefined
|
||||
record.provideInfo ??= this.channel.materializeInfo(this.bindingOf(id as SessionId, record))
|
||||
return record.provideInfo
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the current-session-optional standard kit (host face member):
|
||||
* unknown or absent ids return the static no-session projection.
|
||||
* @param id - current session id, when selected.
|
||||
* @returns a definite or no-session provide bundle.
|
||||
*/
|
||||
maybeProvideInfo(id: string | undefined): SessionMaybeProvideInfo {
|
||||
return (id === undefined ? undefined : this.provideInfo(id)) ?? this.channel.maybeInfo
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve (mint on first touch) the session-scoped Cordis context through
|
||||
* the production `createScope`, so real `scopeOf`/scope-addressed services
|
||||
* resolve it.
|
||||
* @param id - session id.
|
||||
* @returns the scoped context, or undefined for unknown sessions.
|
||||
*/
|
||||
scope(id: string): Context | undefined {
|
||||
const record = this.records.get(id as SessionId)
|
||||
if (record === undefined) return undefined
|
||||
if (record.scope === undefined) {
|
||||
const handle = createScope(this.rootCtx, id as SessionId)
|
||||
record.scope = handle.ctx
|
||||
record.scopeFiber = handle.fiber
|
||||
}
|
||||
return record.scope
|
||||
}
|
||||
|
||||
/**
|
||||
* Session assembly binding (inject factories and provide resolvers receive it).
|
||||
* @param id - session id.
|
||||
* @returns sessionId + behavior face + scoped ctx, or undefined when unknown.
|
||||
*/
|
||||
binding(id: string): TestSessionBinding | undefined {
|
||||
const record = this.records.get(id as SessionId)
|
||||
if (record === undefined) return undefined
|
||||
return this.bindingOf(id as SessionId, record)
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the session scope tag off a context (service-method seam mirror).
|
||||
* @param ctx - any client context.
|
||||
* @returns the session id, or undefined on root contexts.
|
||||
*/
|
||||
scopeOf(ctx: Context): SessionId | undefined {
|
||||
return scopeOf(ctx)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the scoped session face off a context (production `sessionOf`
|
||||
* mirror).
|
||||
* @param ctx - any client context.
|
||||
* @returns the fixture session face, or undefined off-scope.
|
||||
*/
|
||||
sessionOf(ctx: Context): SessionFace | undefined {
|
||||
const id = scopeOf(ctx)
|
||||
if (id === undefined) return undefined
|
||||
return this.records.get(id)?.session
|
||||
}
|
||||
|
||||
/**
|
||||
* Service-level selection call (recorded, then applied to the list store
|
||||
* synchronously — inject callbacks call this outside any act window; the
|
||||
* store notify is microtask-batched so the next stabilized step observes it).
|
||||
* @param id - session id.
|
||||
*/
|
||||
open(id: SessionId): void {
|
||||
this.calls.push({ method: 'open', args: [id] })
|
||||
this.require(id)
|
||||
this.list.update((draft) => { draft.current = id })
|
||||
}
|
||||
|
||||
/** Clear the current selection (recorded; the production no-session flow). */
|
||||
clear(): void {
|
||||
this.calls.push({ method: 'clear', args: [] })
|
||||
this.list.update((draft) => { draft.current = undefined })
|
||||
}
|
||||
|
||||
/**
|
||||
* The session face of a fixture (typed view for assertions; fixture
|
||||
* behavior methods are grafted onto it).
|
||||
* @param id - session id.
|
||||
* @returns the FixtureSession the binding and provide channel carry.
|
||||
*/
|
||||
behavior(id: string): FixtureSession {
|
||||
return this.require(id).session
|
||||
}
|
||||
|
||||
/** Dispose minted scope fibers (runtime dispose path). */
|
||||
async disposeScopes(): Promise<void> {
|
||||
for (const record of this.records.values()) {
|
||||
if (record.scopeFiber !== undefined) {
|
||||
await record.scopeFiber.dispose()
|
||||
record.scope = undefined
|
||||
record.scopeFiber = undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private bindingOf(id: SessionId, record: SessionRecord): TestSessionBinding {
|
||||
const ctx = this.scope(id)
|
||||
/* v8 ignore next 2 -- bindingOf only runs for a live record, whose scope
|
||||
* always resolves; kept so a future caller cannot mint a ctx-less binding. */
|
||||
if (ctx === undefined) throw new Error(`test session "${id}" resolved no scope`)
|
||||
return { sessionId: id, session: record.session, ctx }
|
||||
}
|
||||
|
||||
private require(id: string): SessionRecord {
|
||||
const record = this.records.get(id as SessionId)
|
||||
if (record === undefined) throw new Error(`test session "${id}" is not added`)
|
||||
return record
|
||||
}
|
||||
}
|
||||
89
packages/client/test-runtime/src/snapshot.ts
Normal file
89
packages/client/test-runtime/src/snapshot.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* DOM snapshot hygiene: a vitest snapshot serializer that keeps `.snap`
|
||||
* files structural. Two normalizations, both on a clone (the live DOM is
|
||||
* untouched, so class/tag queries keep working):
|
||||
*
|
||||
* - CSS-module scoped class names (`_frame_334d2d`, this repo's
|
||||
* `_[local]_[hash]` shape) fold back to their semantic local (`frame`), so
|
||||
* CSS edits do not churn snapshots.
|
||||
* - `<svg>` internals collapse to a `data-content` fingerprint on the svg
|
||||
* element: path geometry is print noise, but the fingerprint still flips
|
||||
* when an icon's artwork actually changes.
|
||||
*/
|
||||
import { expect } from 'vitest'
|
||||
import type { SnapshotSerializer } from 'vitest'
|
||||
|
||||
/** One scoped class token: `_<local>_<hash>` (local may itself contain underscores). */
|
||||
const SCOPED_CLASS = /^_(.+)_[a-z0-9]+$/
|
||||
|
||||
/** Fold scoped tokens in one class attribute value; foreign tokens pass through. */
|
||||
function normalizeClassValue(value: string): string {
|
||||
return value
|
||||
.split(/\s+/)
|
||||
.filter(token => token !== '')
|
||||
.map(token => token.replace(SCOPED_CLASS, '$1'))
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
/** FNV-1a 32-bit over the svg markup: deterministic, dependency-free fingerprint. */
|
||||
function fingerprint(markup: string): string {
|
||||
let hash = 0x811c9dc5
|
||||
for (let i = 0; i < markup.length; i++) {
|
||||
hash ^= markup.charCodeAt(i)
|
||||
hash = Math.imul(hash, 0x01000193)
|
||||
}
|
||||
return (hash >>> 0).toString(16).padStart(8, '0')
|
||||
}
|
||||
|
||||
/** svg elements of a subtree, the root included when it is one. */
|
||||
function svgsOf(root: Element): Element[] {
|
||||
const svgs: Element[] = [...root.querySelectorAll('svg')]
|
||||
if (root.tagName.toLowerCase() === 'svg') svgs.unshift(root)
|
||||
return svgs
|
||||
}
|
||||
|
||||
/** Whether serializing this subtree needs a normalized clone. */
|
||||
function needsNormalization(root: Element): boolean {
|
||||
const scoped = [root, ...root.querySelectorAll('[class]')].some((el) => {
|
||||
const value = el.getAttribute('class')
|
||||
return value !== null && value.split(/\s+/).some(token => SCOPED_CLASS.test(token))
|
||||
})
|
||||
return scoped || svgsOf(root).some(svg => svg.childNodes.length > 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* The serializer plugin. Matches DOM elements whose subtree carries a scoped
|
||||
* class or svg internals; serializes a normalized clone, which no longer
|
||||
* matches, so printing falls through to the built-in DOM element serializer.
|
||||
*/
|
||||
export const domSnapshotSerializer: SnapshotSerializer = {
|
||||
test(value: unknown): boolean {
|
||||
return typeof Element !== 'undefined' && value instanceof Element && needsNormalization(value)
|
||||
},
|
||||
serialize(value, config, indentation, depth, refs, printer): string {
|
||||
const clone = (value as Element).cloneNode(true) as Element
|
||||
for (const el of [clone, ...clone.querySelectorAll('[class]')]) {
|
||||
const raw = el.getAttribute('class')
|
||||
if (raw !== null) el.setAttribute('class', normalizeClassValue(raw))
|
||||
}
|
||||
for (const svg of svgsOf(clone)) {
|
||||
if (svg.childNodes.length === 0) continue
|
||||
svg.setAttribute('data-content', fingerprint(svg.innerHTML))
|
||||
svg.replaceChildren()
|
||||
}
|
||||
return printer(clone, config, indentation, depth, refs)
|
||||
},
|
||||
}
|
||||
|
||||
let registered = false
|
||||
|
||||
/**
|
||||
* Register {@link domSnapshotSerializer} with vitest's expect (idempotent).
|
||||
* SlotTestRuntime.create() calls this; specs that snapshot DOM outside the
|
||||
* runtime import and call it themselves.
|
||||
*/
|
||||
export function registerDomSnapshotSerializer(): void {
|
||||
if (registered) return
|
||||
registered = true
|
||||
expect.addSnapshotSerializer(domSnapshotSerializer)
|
||||
}
|
||||
147
packages/client/test-runtime/src/workspaces.ts
Normal file
147
packages/client/test-runtime/src/workspaces.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
/** Test-owned workspaces face: the renderer standard-kit observable plus recorded actions. */
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {
|
||||
IWorkspaces, SessionId, SnapshotStore, WorkspaceId, WorkspaceListState, WorkspaceView,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { workspaceListState } from './fixtures.ts'
|
||||
import type { Stabilizer } from './fixtures.ts'
|
||||
|
||||
/**
|
||||
* Workspaces test double. Implements the same IWorkspaces face features
|
||||
* receive as `ctx.workspaces`, so a production face change breaks this
|
||||
* double at compile time. Every action records into {@link
|
||||
* TestWorkspaces.calls}; defaults are inert echoes — feature tests needing
|
||||
* richer behavior replace them via {@link TestWorkspaces.stub}.
|
||||
*/
|
||||
export class TestWorkspaces implements IWorkspaces {
|
||||
/** The useWorkspaces standard feed. */
|
||||
readonly list: SnapshotStore<WorkspaceListState>
|
||||
|
||||
/** Calls observed on the action face, newest last. */
|
||||
readonly calls: { method: string; args: unknown[] }[] = []
|
||||
|
||||
/** Replaceable action seat: feature tests may stub richer behavior. */
|
||||
private readonly stubs = new Map<string, (...args: unknown[]) => unknown>()
|
||||
|
||||
/**
|
||||
* @param stabilize - the owning runtime's act wrapper.
|
||||
*/
|
||||
constructor(private readonly stabilize: Stabilizer) {
|
||||
this.list = createSnapshotStore<WorkspaceListState>(workspaceListState())
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the workspace list state through an immer draft.
|
||||
* @param mutate - draft mutator.
|
||||
*/
|
||||
async update(mutate: (draft: WorkspaceListState) => void): Promise<void> {
|
||||
await this.stabilize(() => { this.list.update(mutate) })
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace an action's behavior (the recorded call is still appended first).
|
||||
* @param method - action name (e.g. 'connectWorkspace').
|
||||
* @param impl - replacement behavior.
|
||||
*/
|
||||
stub(method: string, impl: (...args: unknown[]) => unknown): void {
|
||||
this.stubs.set(method, impl)
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect a workspace to its reusable/new blank session (recorded). The
|
||||
* default resolves the workspace id back as the session id; stub for
|
||||
* cross-session flows.
|
||||
* @param workspaceId - target workspace.
|
||||
* @returns the connected session id.
|
||||
*/
|
||||
async connectWorkspace(workspaceId: WorkspaceId): Promise<SessionId> {
|
||||
this.calls.push({ method: 'connectWorkspace', args: [workspaceId] })
|
||||
const stub = this.stubs.get('connectWorkspace')
|
||||
if (stub !== undefined) return await (stub(workspaceId) as Promise<SessionId>)
|
||||
return `session-of-${workspaceId}` as SessionId
|
||||
}
|
||||
|
||||
/**
|
||||
* New-session flow (recorded; stubbed behavior runs when installed).
|
||||
* @param workspaceId - optional explicit workspace target.
|
||||
*/
|
||||
startSession(workspaceId?: WorkspaceId): void {
|
||||
this.calls.push({ method: 'startSession', args: [workspaceId] })
|
||||
this.stubs.get('startSession')?.(workspaceId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Workspace (recorded). The default echoes a view derived from
|
||||
* the input; stub for failure or list-coupled flows.
|
||||
* @param input - exactly one Host create spelling.
|
||||
* @returns the created Workspace view.
|
||||
*/
|
||||
async create(input: { name: string } | { path: string }): Promise<WorkspaceView> {
|
||||
this.calls.push({ method: 'create', args: [input] })
|
||||
const stub = this.stubs.get('create')
|
||||
if (stub !== undefined) return await (stub(input) as Promise<WorkspaceView>)
|
||||
const title = 'name' in input ? input.name : input.path
|
||||
return {
|
||||
workspaceId: `ws-${title}` as WorkspaceId,
|
||||
title,
|
||||
path: 'path' in input ? input.path : `/${input.name}`,
|
||||
sessionIds: [],
|
||||
} as unknown as WorkspaceView
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a path with the host OS default application (recorded; default no-op).
|
||||
* @param path - host-resolvable path.
|
||||
*/
|
||||
async openPath(path: string): Promise<void> {
|
||||
this.calls.push({ method: 'openPath', args: [path] })
|
||||
await (this.stubs.get('openPath')?.(path) as Promise<void> | undefined)
|
||||
}
|
||||
|
||||
/**
|
||||
* Directory picker (recorded). The default cancels (null); stub to select.
|
||||
* @returns the picked path, or null.
|
||||
*/
|
||||
async pickDirectory(): Promise<string | null> {
|
||||
this.calls.push({ method: 'pickDirectory', args: [] })
|
||||
const stub = this.stubs.get('pickDirectory')
|
||||
if (stub !== undefined) return await (stub() as Promise<string | null>)
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a Workspace (recorded). The default echoes a minimal view.
|
||||
* @param workspaceId - target workspace.
|
||||
* @param title - new title.
|
||||
* @returns the updated view.
|
||||
*/
|
||||
async rename(workspaceId: WorkspaceId, title: string): Promise<WorkspaceView> {
|
||||
this.calls.push({ method: 'rename', args: [workspaceId, title] })
|
||||
const stub = this.stubs.get('rename')
|
||||
if (stub !== undefined) return await (stub(workspaceId, title) as Promise<WorkspaceView>)
|
||||
return { workspaceId, title, path: `/${title}`, sessionIds: [] } as unknown as WorkspaceView
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a Workspace (recorded; default no-op).
|
||||
* @param workspaceId - target workspace.
|
||||
*/
|
||||
async delete(workspaceId: WorkspaceId): Promise<void> {
|
||||
this.calls.push({ method: 'delete', args: [workspaceId] })
|
||||
await (this.stubs.get('delete')?.(workspaceId) as Promise<void> | undefined)
|
||||
}
|
||||
|
||||
/**
|
||||
* Move an accounted session (recorded). The default echoes a minimal view.
|
||||
* @param workspaceId - target workspace.
|
||||
* @param sessionId - session to move.
|
||||
* @param beforeSessionId - anchor; omitted appends.
|
||||
* @returns the updated view.
|
||||
*/
|
||||
async insertSessionBefore(workspaceId: WorkspaceId, sessionId: SessionId, beforeSessionId?: SessionId): Promise<WorkspaceView> {
|
||||
this.calls.push({ method: 'insertSessionBefore', args: [workspaceId, sessionId, beforeSessionId] })
|
||||
const stub = this.stubs.get('insertSessionBefore')
|
||||
if (stub !== undefined) return await (stub(workspaceId, sessionId, beforeSessionId) as Promise<WorkspaceView>)
|
||||
return { workspaceId, title: '', path: '', sessionIds: [sessionId] } as unknown as WorkspaceView
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`single-slot mounting (declare + renderSlot) > folds class hashes and collapses svg internals in snapshots, leaving the live DOM alone 1`] = `
|
||||
<div
|
||||
data-slot="trt.panel"
|
||||
>
|
||||
<div
|
||||
class="frame plain"
|
||||
>
|
||||
<span
|
||||
class="label"
|
||||
>
|
||||
styled
|
||||
</span>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
data-content="2bfa09dc"
|
||||
viewBox="0 0 16 16"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`single-slot mounting edge arms > serializes childless svg untouched next to scoped classes 1`] = `
|
||||
<div
|
||||
data-slot="trt.panel"
|
||||
>
|
||||
<div
|
||||
class="frame"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
viewBox="0 0 1 1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
12
packages/client/test-runtime/tests/invariant.spec.ts
Normal file
12
packages/client/test-runtime/tests/invariant.spec.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import * as TestRuntimeInvariant from '@deepseek-ai/dsh-client-test-runtime/invariant'
|
||||
import InvariantService from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
describe('invariant companion', () => {
|
||||
it('registers under the package name with an empty installer', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(InvariantService, { enabled: true })
|
||||
await expect(ctx.plugin(TestRuntimeInvariant).await()).resolves.toBeDefined()
|
||||
})
|
||||
})
|
||||
546
packages/client/test-runtime/tests/runtime.spec.tsx
Normal file
546
packages/client/test-runtime/tests/runtime.spec.tsx
Normal file
@@ -0,0 +1,546 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* SlotTestRuntime behavior: root declaration + rendering, session
|
||||
* add/update/switch/remove through the real renderer, shared store identity
|
||||
* and scope pruning, feature mount/dispose cascade, and runtime disposal
|
||||
* idempotence. All through the production SlotsService + createSlotRenderer
|
||||
* stack — this suite is the fixture the migrated feature specs rely on.
|
||||
*/
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup } from '@testing-library/react'
|
||||
import { defineStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SessionId, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { PropsRenderSlots, SessionStandardProps } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
interface SlotMap {
|
||||
'trt.panel': { kind: 'single'; scope: 'root'; owner: { label?: string } }
|
||||
'trt.chat': { kind: 'single'; scope: 'session' }
|
||||
'trt.rows': { kind: 'list'; scope: 'root' }
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
type FrameProps = PropsRenderSlots<'trt.panel' | 'trt.chat' | 'trt.rows'>
|
||||
|
||||
/** Root frame declaring all three suite slots (render sites for each kind). */
|
||||
function Frame({ renderSlot, SessionProvider }: FrameProps) {
|
||||
return (
|
||||
<>
|
||||
{renderSlot('trt.panel', { label: 'from-owner' }, { fallback: <i>no panel</i> })}
|
||||
<SessionProvider empty={() => <i>no session</i>}>
|
||||
{() => renderSlot('trt.chat', {})}
|
||||
</SessionProvider>
|
||||
{renderSlot('trt.rows', {})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const CHILDREN = {
|
||||
'trt.panel': { kind: 'single', scope: 'root' },
|
||||
'trt.chat': { kind: 'single', scope: 'session' },
|
||||
'trt.rows': { kind: 'list', scope: 'root' },
|
||||
} as const
|
||||
|
||||
async function runtimeWithFrame() {
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
await runtime.root.declare(CHILDREN, Frame)
|
||||
return runtime
|
||||
}
|
||||
|
||||
describe('root declaration and rendering', () => {
|
||||
it('renders declared slots through the real renderer: fallback, then a live registration, then unload', async () => {
|
||||
const runtime = await runtimeWithFrame()
|
||||
const view = runtime.renderRoot()
|
||||
expect(view.container.textContent).toContain('no panel')
|
||||
|
||||
let dispose = (): void => {}
|
||||
await runtime.flush() // no-op guard: flush outside mutations is safe
|
||||
await (async () => {
|
||||
dispose = runtime.slots.register(
|
||||
{ name: 'trt.panel' },
|
||||
({ label }: { label?: string }) => <b>panel:{label}</b>)
|
||||
await runtime.flush()
|
||||
})()
|
||||
expect(view.container.textContent).toContain('panel:from-owner')
|
||||
dispose()
|
||||
await runtime.flush()
|
||||
expect(view.container.textContent).toContain('no panel')
|
||||
await runtime.dispose()
|
||||
})
|
||||
|
||||
it('fails loud when rendering with no root declaration (production boot-order check)', async () => {
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
expect(() => runtime.renderRoot()).toThrow(/'root' has no registration/)
|
||||
await runtime.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
describe('sessions', () => {
|
||||
it('drives SessionProvider: empty state, current session, switch, live snapshot updates', async () => {
|
||||
const runtime = await runtimeWithFrame()
|
||||
runtime.slots.register({ name: 'trt.chat' }, (props: SessionStandardProps) => {
|
||||
const running = props.useSession(s => s.running)
|
||||
return <span>chat:{props.sessionId}:{String(running)}</span>
|
||||
})
|
||||
const view = runtime.renderRoot()
|
||||
expect(view.container.textContent).toContain('no session')
|
||||
|
||||
await runtime.sessions.add({ id: 's1' })
|
||||
expect(view.container.textContent).toContain('chat:s1:false')
|
||||
|
||||
await runtime.sessions.updateSnapshot('s1', (draft) => { draft.running = true })
|
||||
expect(view.container.textContent).toContain('chat:s1:true')
|
||||
|
||||
await runtime.sessions.add({ id: 's2' }) // becomes current by default
|
||||
expect(view.container.textContent).toContain('chat:s2:false')
|
||||
|
||||
await runtime.sessions.setCurrent(undefined)
|
||||
expect(view.container.textContent).toContain('no session')
|
||||
await runtime.sessions.setCurrent('s1')
|
||||
expect(view.container.textContent).toContain('chat:s1:true')
|
||||
await runtime.dispose()
|
||||
})
|
||||
|
||||
it('add with current:false keeps the selection; unknown ids fail loud on the mutators', async () => {
|
||||
const runtime = await runtimeWithFrame()
|
||||
await runtime.sessions.add({ id: 's1' })
|
||||
await runtime.sessions.add({ id: 's2' }, { current: false })
|
||||
expect(runtime.sessions.list.getSnapshot().current).toBe('s1')
|
||||
expect(runtime.sessions.list.getSnapshot().ids).toEqual(['s1', 's2'])
|
||||
await expect(runtime.sessions.add({ id: 's1' })).rejects.toThrow(/already added/)
|
||||
await expect(runtime.sessions.setCurrent('ghost')).rejects.toThrow(/not added/)
|
||||
await expect(runtime.sessions.updateSnapshot('ghost', () => {})).rejects.toThrow(/not added/)
|
||||
await expect(runtime.sessions.remove('ghost')).rejects.toThrow(/not added/)
|
||||
expect(() => runtime.sessions.behavior('ghost')).toThrow(/not added/)
|
||||
await runtime.dispose()
|
||||
})
|
||||
|
||||
it('mints REAL-tag scopes lazily and resolves them through the production scopeOf; bindings expose the behavior face', async () => {
|
||||
const runtime = await runtimeWithFrame()
|
||||
const prompt = vi.fn()
|
||||
await runtime.sessions.add({ id: 's1', session: { prompt } })
|
||||
|
||||
expect(runtime.sessions.provideInfo('ghost')).toBeUndefined()
|
||||
expect(runtime.sessions.scope('ghost')).toBeUndefined()
|
||||
expect(runtime.sessions.binding('ghost')).toBeUndefined()
|
||||
|
||||
const scope = runtime.sessions.scope('s1')!
|
||||
expect(runtime.sessions.scope('s1')).toBe(scope) // stable per session
|
||||
expect(runtime.sessions.scopeOf(scope)).toBe('s1')
|
||||
expect(runtime.sessions.scopeOf(runtime.ctx)).toBeUndefined()
|
||||
// sessionOf resolves the behavior face off the scope tag.
|
||||
expect(runtime.sessions.sessionOf(scope)).toBe(runtime.sessions.behavior('s1'))
|
||||
expect(runtime.sessions.sessionOf(runtime.ctx)).toBeUndefined()
|
||||
|
||||
const binding = runtime.sessions.binding('s1')!
|
||||
expect(binding.sessionId).toBe('s1')
|
||||
expect(binding.ctx).toBe(scope)
|
||||
;(binding.session as { prompt: () => void }).prompt()
|
||||
expect(prompt).toHaveBeenCalledOnce()
|
||||
expect(runtime.sessions.behavior('s1')).toBe(binding.session)
|
||||
// The binding's session doubles as the conversation observable face.
|
||||
expect((binding.session as { getSnapshot(): { sessionId: string } }).getSnapshot().sessionId).toBe('s1')
|
||||
|
||||
// A scoped service resolves through the scope ctx (scope-addressed pattern).
|
||||
runtime.provide('probe', { hello: 'world' })
|
||||
expect(scope.get('probe')).toEqual({ hello: 'world' })
|
||||
await runtime.dispose()
|
||||
})
|
||||
|
||||
it('materializes provide bundles: built-in session hook, custom providers, no-session projection', async () => {
|
||||
const runtime = await runtimeWithFrame()
|
||||
await runtime.sessions.add({ id: 's1' })
|
||||
|
||||
const info = runtime.sessions.provideInfo('s1')!
|
||||
expect(info.sessionId).toBe('s1')
|
||||
expect(info.hooks['session']).toBeDefined() // the built-in useSession source
|
||||
expect(runtime.sessions.provideInfo('s1')).toBe(info) // identity-stable
|
||||
|
||||
// A feature provider (the ui-conversation input pattern): declared names
|
||||
// materialize per session and land in the no-session roster as undefined.
|
||||
const off = runtime.sessions.provide({
|
||||
hooks: ['probe'],
|
||||
props: ['probeActions'],
|
||||
resolve: binding => ({
|
||||
hooks: { probe: { getSnapshot: () => binding.sessionId, subscribe: () => () => {} } },
|
||||
props: { probeActions: { poke: () => {} } },
|
||||
}),
|
||||
})
|
||||
const enriched = runtime.sessions.provideInfo('s1')!
|
||||
expect(enriched.hooks['probe']?.getSnapshot()).toBe('s1')
|
||||
expect(enriched.props['probeActions']).toBeDefined()
|
||||
const maybe = runtime.sessions.maybeProvideInfo(undefined)
|
||||
expect(maybe.sessionId).toBeUndefined()
|
||||
expect(Object.keys(maybe.hooks)).toEqual(['session', 'probe'])
|
||||
expect(runtime.sessions.maybeProvideInfo('s1')).toBe(runtime.sessions.provideInfo('s1'))
|
||||
expect(runtime.sessions.maybeProvideInfo('ghost').sessionId).toBeUndefined()
|
||||
|
||||
// Misdeclared providers fail loud AT REGISTRATION (the production
|
||||
// channel rebuilds live bundles eagerly and rolls the roster back):
|
||||
// missing hook, missing prop, duplicate hook, duplicate prop.
|
||||
expect(() => runtime.sessions.provide({ hooks: ['void'], resolve: () => ({}) }))
|
||||
.toThrow(/missing hook "void"/)
|
||||
expect(() => runtime.sessions.provide({ props: ['void'], resolve: () => ({}) }))
|
||||
.toThrow(/missing prop "void"/)
|
||||
expect(() => runtime.sessions.provide({
|
||||
hooks: ['session'],
|
||||
resolve: () => ({ hooks: { session: { getSnapshot: () => 0, subscribe: () => () => {} } } }),
|
||||
})).toThrow(/duplicate hook "session"/)
|
||||
const propA = runtime.sessions.provide({ props: ['twice'], resolve: () => ({ props: { twice: 1 } }) })
|
||||
expect(() => runtime.sessions.provide({ props: ['twice'], resolve: () => ({ props: { twice: 2 } }) }))
|
||||
.toThrow(/duplicate prop "twice"/)
|
||||
propA()
|
||||
// The rejected registrations rolled back: the roster still materializes.
|
||||
expect(runtime.sessions.provideInfo('s1')).toBeDefined()
|
||||
off()
|
||||
off() // disposer is idempotent
|
||||
expect(Object.keys(runtime.sessions.maybeProvideInfo(undefined).hooks)).toEqual(['session'])
|
||||
await runtime.dispose()
|
||||
})
|
||||
|
||||
it('records service-face calls; open() moves the selection and clear() empties it', async () => {
|
||||
const runtime = await runtimeWithFrame()
|
||||
await runtime.sessions.add({ id: 's1' })
|
||||
await runtime.sessions.add({ id: 's2' })
|
||||
runtime.sessions.open('s1' as SessionId)
|
||||
await runtime.flush()
|
||||
expect(runtime.sessions.list.getSnapshot().current).toBe('s1')
|
||||
runtime.sessions.clear()
|
||||
await runtime.flush()
|
||||
expect(runtime.sessions.list.getSnapshot().current).toBeUndefined()
|
||||
expect(runtime.sessions.calls).toEqual([
|
||||
{ method: 'open', args: ['s1'] },
|
||||
{ method: 'clear', args: [] },
|
||||
])
|
||||
await runtime.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
describe('stores', () => {
|
||||
const createSuiteStore = () => defineStore({
|
||||
init: () => ({ note: '' }),
|
||||
persist: 'trt.store',
|
||||
actions: { setNote: (d, note: string) => { d.note = note } },
|
||||
})
|
||||
|
||||
it('resolves per-session instances via the host face: shared identity, isolation, action-driven re-render', async () => {
|
||||
const runtime = await runtimeWithFrame()
|
||||
const handle = createSuiteStore()
|
||||
runtime.slots.register(
|
||||
{ name: 'trt.chat', store: handle },
|
||||
(props: SessionStandardProps & { useStore: <S>(sel: (s: { note: string }) => S) => S }) =>
|
||||
<span>note:{props.useStore(s => s.note)}</span>)
|
||||
const view = runtime.renderRoot()
|
||||
await runtime.sessions.add({ id: 's1' })
|
||||
|
||||
expect(() => runtime.storeOf('trt.panel')).toThrow(/no registration/)
|
||||
const store = runtime.storeOf('trt.chat', 's1')
|
||||
await runtime.flush()
|
||||
;(store.actions['setNote'] as (note: string) => void)('hello')
|
||||
await runtime.flush()
|
||||
expect(view.container.textContent).toContain('note:hello')
|
||||
expect(runtime.storeOf('trt.chat', 's1')).toBe(store) // cached per scope key
|
||||
|
||||
await runtime.sessions.add({ id: 's2' })
|
||||
const other = runtime.storeOf('trt.chat', 's2')
|
||||
expect(other).not.toBe(store)
|
||||
expect(other.getSnapshot()).toEqual({ note: '' })
|
||||
await runtime.dispose()
|
||||
})
|
||||
|
||||
it('storeOf guards: before renderRoot, and for storeless entries', async () => {
|
||||
const runtime = await runtimeWithFrame()
|
||||
runtime.slots.register({ name: 'trt.panel' }, () => null)
|
||||
expect(() => runtime.storeOf('trt.panel')).toThrow(/before renderRoot/)
|
||||
runtime.renderRoot()
|
||||
expect(() => runtime.storeOf('trt.panel')).toThrow(/declares no store/)
|
||||
await runtime.dispose()
|
||||
})
|
||||
|
||||
it('remove() prunes the session store scope: persisted state clears, a re-added session starts fresh', async () => {
|
||||
const runtime = await runtimeWithFrame()
|
||||
const handle = createSuiteStore()
|
||||
runtime.slots.register({ name: 'trt.chat', store: handle }, () => null)
|
||||
runtime.renderRoot()
|
||||
await runtime.sessions.add({ id: 's1' })
|
||||
|
||||
const doomed = runtime.storeOf('trt.chat', 's1')
|
||||
;(doomed.actions['setNote'] as (note: string) => void)('buried')
|
||||
expect(localStorage.getItem('trt.store.s1')).not.toBeNull()
|
||||
|
||||
await runtime.sessions.remove('s1')
|
||||
expect(localStorage.getItem('trt.store.s1')).toBeNull()
|
||||
expect(runtime.sessions.list.getSnapshot().ids).toEqual([])
|
||||
expect(runtime.sessions.provideInfo('s1')).toBeUndefined()
|
||||
|
||||
await runtime.sessions.add({ id: 's1' })
|
||||
const reborn = runtime.storeOf('trt.chat', 's1')
|
||||
expect(reborn).not.toBe(doomed)
|
||||
expect(reborn.getSnapshot()).toEqual({ note: '' })
|
||||
await runtime.dispose()
|
||||
})
|
||||
|
||||
it('remove() also disposes a minted scope fiber; removing a non-current session keeps the selection', async () => {
|
||||
const runtime = await runtimeWithFrame()
|
||||
await runtime.sessions.add({ id: 's1' })
|
||||
await runtime.sessions.add({ id: 's2' }, { current: false })
|
||||
const scope = runtime.sessions.scope('s1')!
|
||||
await runtime.sessions.remove('s2')
|
||||
expect(runtime.sessions.list.getSnapshot().current).toBe('s1')
|
||||
await runtime.sessions.remove('s1')
|
||||
expect(scope.fiber.uid).toBeNull() // disposed fiber loses its uid
|
||||
expect(runtime.sessions.list.getSnapshot().current).toBeUndefined()
|
||||
await runtime.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
describe('workspaces', () => {
|
||||
it('feeds useWorkspaces and records/stubs intent actions', async () => {
|
||||
const runtime = await runtimeWithFrame()
|
||||
runtime.slots.register(
|
||||
{ name: 'trt.panel' },
|
||||
(props: { useWorkspaces: <S>(sel: (s: { phase: string }) => S) => S }) =>
|
||||
<span>ws:{props.useWorkspaces(s => s.phase)}</span>)
|
||||
const view = runtime.renderRoot()
|
||||
expect(view.container.textContent).toContain('ws:ready')
|
||||
|
||||
await runtime.workspaces.update((draft) => { draft.phase = 'pending' })
|
||||
expect(view.container.textContent).toContain('ws:pending')
|
||||
|
||||
runtime.workspaces.startSession('w1' as WorkspaceId)
|
||||
await expect(runtime.workspaces.connectWorkspace('w2' as WorkspaceId)).resolves.toBe('session-of-w2')
|
||||
expect(runtime.workspaces.calls).toEqual([
|
||||
{ method: 'startSession', args: ['w1'] },
|
||||
{ method: 'connectWorkspace', args: ['w2'] },
|
||||
])
|
||||
const stub = vi.fn(() => Promise.resolve('other' as never))
|
||||
runtime.workspaces.stub('connectWorkspace', stub)
|
||||
await expect(runtime.workspaces.connectWorkspace('w3' as WorkspaceId)).resolves.toBe('other')
|
||||
expect(stub).toHaveBeenCalledOnce()
|
||||
await runtime.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
describe('feature mount and disposal', () => {
|
||||
it('mounts a plugin on a real fiber; dispose() cascades entries, declared children, and services', async () => {
|
||||
const runtime = await runtimeWithFrame()
|
||||
runtime.provide('layout', { openDetails: vi.fn() })
|
||||
const feature = await runtime.mount({
|
||||
inject: ['slots', 'layout'],
|
||||
apply: (ctx: typeof runtime.ctx) => {
|
||||
ctx.provide('feature-service', { ok: true })
|
||||
ctx.slots.register({
|
||||
name: 'trt.rows',
|
||||
id: 'row-1',
|
||||
children: { 'trt.rows.hole': { kind: 'single', scope: 'root' } },
|
||||
} as never, ((props: { renderSlot: (key: string, owner: object) => unknown }) =>
|
||||
<div data-testid="row">{props.renderSlot('trt.rows.hole', {}) as React.ReactNode}</div>) as never)
|
||||
},
|
||||
})
|
||||
const view = runtime.renderRoot()
|
||||
expect(view.getByTestId('row')).toBeTruthy()
|
||||
expect(runtime.ctx.get('feature-service')).toEqual({ ok: true })
|
||||
expect(runtime.slots.entries('trt.rows')).toHaveLength(1)
|
||||
|
||||
await feature.dispose()
|
||||
await feature.dispose() // idempotent
|
||||
expect(runtime.slots.entries('trt.rows')).toHaveLength(0)
|
||||
expect(runtime.slots.spec('trt.rows.hole' as never)).toBeUndefined()
|
||||
expect(runtime.ctx.get('feature-service')).toBeUndefined()
|
||||
expect(view.queryByTestId('row')).toBeNull()
|
||||
await runtime.dispose()
|
||||
})
|
||||
|
||||
it('mount fails loud on missing services instead of suspending forever', async () => {
|
||||
const runtime = await runtimeWithFrame()
|
||||
await expect(runtime.mount({ inject: ['slots', 'absent-service'], apply: () => {} }))
|
||||
.rejects.toThrow(/missing service\(s\) absent-service/)
|
||||
await runtime.dispose()
|
||||
})
|
||||
|
||||
it('runtime dispose is idempotent, unmounts views, disposes mounted features, and clears persisted state', async () => {
|
||||
const runtime = await runtimeWithFrame()
|
||||
const feature = await runtime.mount({
|
||||
inject: ['slots'],
|
||||
apply: (ctx: typeof runtime.ctx) => { ctx.slots.register({ name: 'trt.panel' }, () => <b>p</b>) },
|
||||
})
|
||||
const view = runtime.renderRoot()
|
||||
expect(view.container.textContent).toContain('p')
|
||||
localStorage.setItem('trt.leftover', 'x')
|
||||
|
||||
await runtime.dispose()
|
||||
expect(view.container.innerHTML).toBe('')
|
||||
expect(feature.fiber.uid).toBeNull()
|
||||
expect(localStorage.getItem('trt.leftover')).toBeNull()
|
||||
await runtime.dispose() // idempotent
|
||||
await expect(runtime.dispose()).resolves.toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('single-slot mounting (declare + renderSlot)', () => {
|
||||
it('renders one slot inside its data-slot wrapper and updates owner props in place', async () => {
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
await runtime.declare({ 'trt.panel': { kind: 'single', scope: 'root' } })
|
||||
runtime.slots.register(
|
||||
{ name: 'trt.panel' },
|
||||
({ label }: { label?: string }) => <b data-testid="panel">{label ?? 'none'}</b>)
|
||||
const slot = runtime.renderSlot('trt.panel', { label: 'first' })
|
||||
expect(slot.container.getAttribute('data-slot')).toBe('trt.panel')
|
||||
expect(slot.view.getByTestId('panel').textContent).toBe('first')
|
||||
|
||||
const panel = slot.view.getByTestId('panel')
|
||||
slot.update({ label: 'second' })
|
||||
expect(slot.view.getByTestId('panel').textContent).toBe('second')
|
||||
// In-place re-render: the element identity survived the owner flip.
|
||||
expect(slot.view.getByTestId('panel')).toBe(panel)
|
||||
await runtime.dispose()
|
||||
})
|
||||
|
||||
it('views sibling slots of one tree separately and rejects undeclared keys', async () => {
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
await runtime.declare({
|
||||
'trt.panel': { kind: 'single', scope: 'root' },
|
||||
'trt.rows': { kind: 'list', scope: 'root' },
|
||||
})
|
||||
runtime.slots.register({ name: 'trt.panel' }, () => <b>panel</b>)
|
||||
runtime.slots.register({ name: 'trt.rows', id: 'r1' }, () => <i>row</i>)
|
||||
const panel = runtime.renderSlot('trt.panel', {})
|
||||
const rows = runtime.renderSlot('trt.rows', {})
|
||||
expect(panel.container.textContent).toBe('panel')
|
||||
expect(rows.container.textContent).toBe('row')
|
||||
expect(() => runtime.renderSlot('trt.chat', {})).toThrow(/without declare\(\)/)
|
||||
await runtime.dispose()
|
||||
})
|
||||
|
||||
it('folds class hashes and collapses svg internals in snapshots, leaving the live DOM alone', async () => {
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
await runtime.declare({ 'trt.panel': { kind: 'single', scope: 'root' } })
|
||||
runtime.slots.register({ name: 'trt.panel' }, () => (
|
||||
<div className="_frame_a1b2c3 plain">
|
||||
<span className="_label_ff00aa">styled</span>
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true">
|
||||
<path d="M0 0L16 16" fill="currentColor" />
|
||||
</svg>
|
||||
</div>
|
||||
))
|
||||
const slot = runtime.renderSlot('trt.panel', {})
|
||||
expect(slot.container).toMatchSnapshot()
|
||||
// The serializer works on a clone: the live DOM keeps hashes and paths.
|
||||
expect(slot.container.querySelector('div')!.className).toBe('_frame_a1b2c3 plain')
|
||||
expect(slot.container.querySelector('svg path')).not.toBeNull()
|
||||
await runtime.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
describe('fixture session face', () => {
|
||||
it('fail-loud stubs name the missing verb; supplied overrides run instead', async () => {
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
await runtime.sessions.add({ id: 's1' })
|
||||
const bare = runtime.sessions.behavior('s1')
|
||||
expect(() => bare.prompt()).toThrow(/prompt is not stubbed/)
|
||||
expect(() => bare.cancel()).toThrow(/cancel is not stubbed/)
|
||||
expect(() => bare.loadOlder()).toThrow(/loadOlder is not stubbed/)
|
||||
await runtime.dispose()
|
||||
})
|
||||
|
||||
it('projections faces are identity-stable per key, read absent, and notify on set', async () => {
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
await runtime.sessions.add({ id: 's1' })
|
||||
const session = runtime.sessions.behavior('s1')
|
||||
const face = session.projections.faceOf('todos')
|
||||
expect(session.projections.faceOf('todos')).toBe(face)
|
||||
expect(face.getSnapshot()).toBeUndefined()
|
||||
const seen: unknown[] = []
|
||||
const off = face.subscribe(() => { seen.push(face.getSnapshot()) })
|
||||
session.projections.set('todos', [1, 2])
|
||||
expect(seen).toEqual([[1, 2]])
|
||||
off()
|
||||
session.projections.set('todos', [3])
|
||||
expect(seen).toEqual([[1, 2]]) // unsubscribed
|
||||
// A never-subscribed key sets without listeners (the empty-notify arm).
|
||||
session.projections.set('untouched', 1)
|
||||
// The provide bundle hands the same store to the render side.
|
||||
const info = runtime.sessions.provideInfo('s1')!
|
||||
expect(info.projections?.faceOf('todos').getSnapshot()).toEqual([3])
|
||||
// A roster change rebuilds the ALREADY-materialized bundle eagerly
|
||||
// (production channel semantics: mounted entries must see the provider)
|
||||
// and skips never-materialized records (they pick the roster up lazily).
|
||||
await runtime.sessions.add({ id: 's-lazy' }, { current: false })
|
||||
const offProbe = runtime.sessions.provide({
|
||||
hooks: ['probe2'],
|
||||
resolve: () => ({ hooks: { probe2: { getSnapshot: () => 1, subscribe: () => () => {} } } }),
|
||||
})
|
||||
const rebuilt = runtime.sessions.provideInfo('s1')!
|
||||
expect(rebuilt).not.toBe(info)
|
||||
expect(rebuilt.hooks['probe2']).toBeDefined()
|
||||
offProbe()
|
||||
await runtime.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
describe('workspaces action face', () => {
|
||||
it('records every IWorkspaces verb with inert defaults and honors stubs', async () => {
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
const ws = runtime.workspaces
|
||||
const created = await ws.create({ name: 'alpha' })
|
||||
expect(created.title).toBe('alpha')
|
||||
const registered = await ws.create({ path: '/tmp/beta' })
|
||||
expect(registered.path).toBe('/tmp/beta')
|
||||
await expect(ws.pickDirectory()).resolves.toBeNull()
|
||||
const renamed = await ws.rename('w1' as WorkspaceId, 'Renamed')
|
||||
expect(renamed.title).toBe('Renamed')
|
||||
await ws.delete('w1' as WorkspaceId)
|
||||
await ws.openPath('/proj/file.ts')
|
||||
const moved = await ws.insertSessionBefore('w1' as WorkspaceId, 's1' as SessionId, 's2' as SessionId)
|
||||
expect(moved.sessionIds).toEqual(['s1'])
|
||||
expect(ws.calls.map(c => c.method)).toEqual(
|
||||
['create', 'create', 'pickDirectory', 'rename', 'delete', 'openPath', 'insertSessionBefore'])
|
||||
|
||||
ws.stub('create', () => Promise.resolve({ workspaceId: 'ws-x', title: 'X', path: '/x', sessionIds: [] } as never))
|
||||
ws.stub('pickDirectory', () => Promise.resolve('/picked'))
|
||||
ws.stub('rename', () => Promise.resolve({ workspaceId: 'w1', title: 'S', path: '/s', sessionIds: [] } as never))
|
||||
ws.stub('delete', () => Promise.resolve())
|
||||
ws.stub('openPath', () => Promise.resolve())
|
||||
ws.stub('insertSessionBefore', () => Promise.resolve({ workspaceId: 'w1', title: '', path: '', sessionIds: [] } as never))
|
||||
expect((await ws.create({ name: 'y' })).title).toBe('X')
|
||||
await expect(ws.pickDirectory()).resolves.toBe('/picked')
|
||||
expect((await ws.rename('w1' as WorkspaceId, 'z')).title).toBe('S')
|
||||
await ws.delete('w1' as WorkspaceId)
|
||||
await ws.openPath('/other')
|
||||
expect((await ws.insertSessionBefore('w1' as WorkspaceId, 's1' as SessionId)).sessionIds).toEqual([])
|
||||
await runtime.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
describe('single-slot mounting edge arms', () => {
|
||||
it('renderSlot fails loud after dispose and after an external unmount', async () => {
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
await runtime.declare({ 'trt.panel': { kind: 'single', scope: 'root' } })
|
||||
runtime.slots.register({ name: 'trt.panel' }, () => <b>p</b>)
|
||||
runtime.renderSlot('trt.panel', {})
|
||||
// RTL cleanup empties the mounted tree behind the runtime's back: the
|
||||
// wrapper lookup names the state instead of returning a dead container.
|
||||
cleanup()
|
||||
expect(() => runtime.renderSlot('trt.panel', {})).toThrow(/rendered no wrapper/)
|
||||
await runtime.dispose()
|
||||
// After dispose the root registration is gone: the production boot-order
|
||||
// check fires before any wrapper lookup.
|
||||
expect(() => runtime.renderSlot('trt.panel', {})).toThrow(/'root' has no registration/)
|
||||
})
|
||||
|
||||
it('serializes childless svg untouched next to scoped classes', async () => {
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
await runtime.declare({ 'trt.panel': { kind: 'single', scope: 'root' } })
|
||||
runtime.slots.register({ name: 'trt.panel' }, () => (
|
||||
<div className="_frame_a1b2c3">
|
||||
<svg viewBox="0 0 1 1" aria-hidden="true" />
|
||||
</div>
|
||||
))
|
||||
const slot = runtime.renderSlot('trt.panel', {})
|
||||
expect(slot.container).toMatchSnapshot()
|
||||
await runtime.dispose()
|
||||
})
|
||||
})
|
||||
27
packages/client/test-runtime/tsconfig.json
Normal file
27
packages/client/test-runtime/tsconfig.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.client.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../ui-slots"
|
||||
},
|
||||
{
|
||||
"path": "../web-react"
|
||||
},
|
||||
{
|
||||
"path": "../runtime"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -9,10 +9,10 @@
|
||||
import { Service } from 'cordis'
|
||||
import type { Context } from 'cordis'
|
||||
import type { ConnectionHandle, SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { ClientContext, SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ClientContext, ISessions } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {
|
||||
CandidateRequest, ClientSessionContext, CommandClaim, PickOutcome, SlashCandidate, SlashPick,
|
||||
SlashServiceContract, SubmitOutcome,
|
||||
SubmitOutcome,
|
||||
} from '@deepseek-ai/dsh-client-ui-slash/client'
|
||||
import type { CommandContribution, CommandServiceContract } from './contract.ts'
|
||||
import type { CommandDescriptor } from './directory.ts'
|
||||
@@ -46,7 +46,7 @@ export class CommandService extends Service implements CommandServiceContract {
|
||||
if (!result.ok) throw new Error(`command.list failed: ${result.error.code}: ${result.error.message}`)
|
||||
return result.value.commands
|
||||
})
|
||||
const slash = ctx.get('slash') as SlashServiceContract | undefined
|
||||
const slash = ctx.get('slash')
|
||||
if (slash === undefined) throw new Error('ui-command: slash service unavailable')
|
||||
ctx.effect(() => slash.registerSource({
|
||||
trigger: '/',
|
||||
@@ -292,7 +292,7 @@ export class CommandService extends Service implements CommandServiceContract {
|
||||
return this.sessions().scope(id)
|
||||
}
|
||||
|
||||
private sessions(): SessionsService {
|
||||
private sessions(): ISessions {
|
||||
const sessions = this.ctx.get('sessions')
|
||||
if (sessions === undefined) throw new Error('ui-command: sessions service unavailable')
|
||||
return sessions
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/** Registers the conversation components, shared store, and service callbacks. */
|
||||
import type { Context } from 'cordis'
|
||||
import type { BoundActions } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { SessionId, SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ISessions, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
|
||||
import type { ViewTab } from './contract/views.ts'
|
||||
import type {
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
import { resolveToolPath } from './contract/tool-call-model.ts'
|
||||
import { createChatStore } from './stores.ts'
|
||||
import { ConversationService } from './service.ts'
|
||||
import type { IConversation } from './service.ts'
|
||||
import { InputHub } from './input/hub.ts'
|
||||
import { InputBar } from './skeleton/InputBar.tsx'
|
||||
import { ChatView } from './chat/ChatView.tsx'
|
||||
@@ -24,8 +25,8 @@ import { DetailsPanel } from './skeleton/DetailsPanel.tsx'
|
||||
/** Services required by the conversation plugin. */
|
||||
export const inject = ['slots', 'layout', 'sessions', 'workspaces']
|
||||
|
||||
/** Resolve the session-scoped conversation service (scope-addressed send/cancel), failing loud. */
|
||||
function scopedConversation(sessions: SessionsService, id: SessionId): ConversationService {
|
||||
/** Resolve the session-scoped conversation face (scope-addressed send/cancel), failing loud. */
|
||||
function scopedConversation(sessions: ISessions, id: SessionId): IConversation {
|
||||
const scoped = sessions.scope(id)
|
||||
if (scoped === undefined) throw new Error(`ui-conversation: session "${id}" resolved no scope`)
|
||||
const conversation = scoped.get('conversation')
|
||||
|
||||
@@ -3,10 +3,9 @@
|
||||
* between the independently implemented skeleton and chat domains; `apply.ts`
|
||||
* owns their slot assembly.
|
||||
*/
|
||||
import type { ConversationService } from './service.ts'
|
||||
|
||||
export { apply, inject } from './apply.ts'
|
||||
export { ConversationService } from './service.ts'
|
||||
export type { IConversation } from './service.ts'
|
||||
|
||||
export type {
|
||||
CallId, ChatStoreState, SelectionTarget, ViewTab,
|
||||
@@ -22,6 +21,7 @@ export type {
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
conversation: ConversationService
|
||||
/** The outward face only; the concrete service stays inside this plugin. */
|
||||
conversation: import('./service.ts').IConversation
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,9 +8,8 @@
|
||||
* bail events) and owns the default-sink choreography: every session is a
|
||||
* real host entity, so the sink is one unconditional prompt path.
|
||||
*/
|
||||
import type { ClientContext, Session, SessionBinding, SessionId, SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SlashController, SlashServiceContract } from '@deepseek-ai/dsh-client-ui-slash/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-slash/client'
|
||||
import type { ClientContext, ISessions, SessionBinding, SessionFace, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SlashController } from '@deepseek-ai/dsh-client-ui-slash/client'
|
||||
import { queueReadFaceOf } from '../queue/store.ts'
|
||||
import type { ComposerKeyboard, InputService, SessionInput } from './contract.ts'
|
||||
import type { PopupDismissFace } from './facade.ts'
|
||||
@@ -113,7 +112,7 @@ export class InputHub implements InputService {
|
||||
* exactly one path; a failed first prompt is an ordinary prompt failure
|
||||
* (error strip via promptError, draft restored only while untouched).
|
||||
*/
|
||||
private sink(session: Session, text: string, mode: 'queue' | 'steer'): void {
|
||||
private sink(session: SessionFace, text: string, mode: 'queue' | 'steer'): void {
|
||||
if (text === '') return
|
||||
const shell = this.shells.get(session.sessionId)
|
||||
// Commit, not an editable clear: undo must not resurrect sent content.
|
||||
@@ -129,7 +128,7 @@ export class InputHub implements InputService {
|
||||
}
|
||||
|
||||
private controller(actx: ClientContext): SlashController | undefined {
|
||||
const slash = this.rootCtx.get('slash') as SlashServiceContract | undefined
|
||||
const slash = this.rootCtx.get('slash')
|
||||
return slash?.sessionOf(actx)
|
||||
}
|
||||
|
||||
@@ -138,7 +137,7 @@ export class InputHub implements InputService {
|
||||
return command?.popupFor(actx)
|
||||
}
|
||||
|
||||
private sessions(): SessionsService {
|
||||
private sessions(): ISessions {
|
||||
const sessions = this.rootCtx.get('sessions')
|
||||
if (sessions === undefined) throw new Error('conversation.input: sessions service unavailable')
|
||||
return sessions
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* reference-stable across unrelated snapshot swaps, so this is a pure
|
||||
* projection — no second store, no copy.
|
||||
*/
|
||||
import type { ObservableSnapshot, Session } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ObservableSnapshot, SessionFace } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { QueuedMessage } from '../input/contract.ts'
|
||||
|
||||
/**
|
||||
@@ -13,10 +13,10 @@ import type { QueuedMessage } from '../input/contract.ts'
|
||||
* The wiring layer (T5) overlays this onto InputState.queue; the runtime
|
||||
* QueuedMessage and the input-contract QueuedMessage are structurally the
|
||||
* same frozen shape ({key, preview}).
|
||||
* @param session - the resident session instance.
|
||||
* @param session - the resident session face.
|
||||
* @returns the queue read face (snapshot reference stable while the queue is unchanged).
|
||||
*/
|
||||
export function queueReadFaceOf(session: Session): ObservableSnapshot<readonly QueuedMessage[]> {
|
||||
export function queueReadFaceOf(session: SessionFace): ObservableSnapshot<readonly QueuedMessage[]> {
|
||||
return {
|
||||
getSnapshot: () => session.getSnapshot().queue,
|
||||
subscribe: fn => session.subscribe(fn),
|
||||
|
||||
@@ -12,24 +12,50 @@ import type { Context } from 'cordis'
|
||||
// Type-only imports: a plugin-to-plugin value import is a bundle purity
|
||||
// error, so scope resolution goes through the sessions service (scopeOf
|
||||
// method) instead of the standalone helper.
|
||||
import type { Session, SessionId, SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { InputHub } from './input/hub.ts'
|
||||
import type { ISessions, SessionFace, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { InputService } from './input/contract.ts'
|
||||
|
||||
/**
|
||||
* The outward conversation face (`ctx.conversation`): the scope-addressed
|
||||
* verbs and the input registry other plugins may reach — and exactly what a
|
||||
* test fake must supply.
|
||||
*/
|
||||
export interface IConversation {
|
||||
/** The per-session input machine registry (InputService face). */
|
||||
readonly input: InputService
|
||||
/**
|
||||
* Send a prompt into the caller scope's session.
|
||||
* @param text - prompt text, sent verbatim as one text block.
|
||||
* @param mode - queue after the current turn, or steer into it.
|
||||
* @returns completion; business failures reject (and land in promptError).
|
||||
*/
|
||||
send(text: string, mode: 'queue' | 'steer'): Promise<void>
|
||||
/**
|
||||
* Cancel the scoped session's in-flight turn.
|
||||
* @returns completion; failures reject as in send.
|
||||
*/
|
||||
cancel(): Promise<void>
|
||||
/**
|
||||
* Pull one older history page for the scoped session.
|
||||
* @returns completion of the page pull.
|
||||
*/
|
||||
loadOlder(): Promise<void>
|
||||
}
|
||||
|
||||
/** Scope-addressed conversation service (root singleton, provided as `conversation`). */
|
||||
export class ConversationService extends Service {
|
||||
export class ConversationService extends Service implements IConversation {
|
||||
/** The per-session input machine registry (InputService face, design §5.2). */
|
||||
readonly input: InputHub
|
||||
readonly input: InputService
|
||||
|
||||
/**
|
||||
* @param ctx - owning root context (the plugin apply context; the service
|
||||
* registers itself and follows that fiber's lifetime).
|
||||
* @param config - the shared InputHub constructed by the plugin apply
|
||||
* (shared with the slot inject factories); absent = own instance
|
||||
* (object-layer tests that never touch slots).
|
||||
* @param config - carries the InputService instance constructed by the
|
||||
* plugin apply (the same InputHub the slot inject factories close over).
|
||||
*/
|
||||
constructor(ctx: Context, config?: { input?: InputHub }) {
|
||||
constructor(ctx: Context, config: { input: InputService }) {
|
||||
super(ctx, 'conversation')
|
||||
this.input = config?.input ?? new InputHub(ctx)
|
||||
this.input = config.input
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -57,8 +83,8 @@ export class ConversationService extends Service {
|
||||
await this.scopedSession('loadOlder').loadOlder()
|
||||
}
|
||||
|
||||
/** Resolve the caller scope's Session or throw on root contexts. */
|
||||
private scopedSession(op: string): Session {
|
||||
/** Resolve the caller scope's session face or throw on root contexts. */
|
||||
private scopedSession(op: string): SessionFace {
|
||||
const id = this.scopeId(op)
|
||||
const binding = this.requireSessions().binding(id)
|
||||
if (binding === undefined) throw new Error(`conversation.${op}: session "${id}" resolved no binding`)
|
||||
@@ -74,10 +100,9 @@ export class ConversationService extends Service {
|
||||
return id
|
||||
}
|
||||
|
||||
private requireSessions(): SessionsService {
|
||||
// ctx.get instead of ctx.sessions: the typed Context merge is suspended
|
||||
// while the client/host `sessions` declaration collision awaits
|
||||
// arbitration (see the runtime package's Context merge note).
|
||||
private requireSessions(): ISessions {
|
||||
// Strict ctx.get, not the injection proxy: the scope-addressed pattern
|
||||
// reads the service off whatever context the tracker rebound.
|
||||
const sessions = this.ctx.get('sessions')
|
||||
if (sessions === undefined) throw new Error('conversation: sessions service unavailable')
|
||||
return sessions
|
||||
|
||||
@@ -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<ReturnType<typeof createChatStore>['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<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 ctx = new Context()
|
||||
const slotsFiber = ctx.plugin(SlotsService)
|
||||
await slotsFiber.await()
|
||||
const slots = ctx.get('slots') as SlotsService
|
||||
|
||||
const listStore = createSnapshotStore<SessionListState>({
|
||||
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<SessionId, Context>()
|
||||
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<string, unknown>
|
||||
props?: Record<string, unknown>
|
||||
}
|
||||
}
|
||||
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<WorkspaceListState>({
|
||||
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()
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
@@ -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<SessionListState>({
|
||||
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<ReturnType<typeof bench>>['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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<ConversationSnapshot>(snapshotWith(nodes))
|
||||
const list = createSnapshotStore<SessionListState>({
|
||||
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<ISession['loadOlder']>(),
|
||||
prompt: vi.fn<ISession['prompt']>(async () => ({ ok: true, value: { accepted: true } })),
|
||||
},
|
||||
})
|
||||
const provideInfo = (id: string) => {
|
||||
if (id !== SID) return undefined
|
||||
if (info === undefined) {
|
||||
const hooks: Record<string, unknown> = { session }
|
||||
const props: Record<string, unknown> = {}
|
||||
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<WorkspaceListState>({
|
||||
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' },
|
||||
() => <div data-testid="mystery-row" />)
|
||||
})
|
||||
dispose = b.slots.register(
|
||||
{ name: 'conversation.chat.toolview', key: 'mystery' },
|
||||
() => <div data-testid="mystery-row" />)
|
||||
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 }) => (
|
||||
<button data-testid="probe-row" onClick={poke}>{mark}</button>
|
||||
))
|
||||
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<SessionListState>({
|
||||
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<WorkspaceListState>({
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<typeof createChatStore>
|
||||
}
|
||||
|
||||
function bench(): Bench {
|
||||
const ctx = new Context()
|
||||
ctx.provide('sessions', {
|
||||
list: createSnapshotStore<SessionListState>({
|
||||
ids: [], byId: {}, current: undefined, phase: 'ready',
|
||||
}),
|
||||
provideInfo: () => undefined,
|
||||
currentProvideInfo: {
|
||||
getSnapshot: () => ABSENT_INFO,
|
||||
subscribe: () => () => {},
|
||||
},
|
||||
provide: () => () => {},
|
||||
})
|
||||
ctx.provide('workspaces', {
|
||||
list: createSnapshotStore<WorkspaceListState>({
|
||||
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<ReturnType<typeof createChatStore>['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 })
|
||||
})
|
||||
})
|
||||
@@ -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<ReturnType<typeof createChatStore>['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<ReturnType<typeof bench>>, 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()
|
||||
})
|
||||
})
|
||||
@@ -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/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -17,14 +17,16 @@ import { ThemePresenter } from './theme-presenter.ts'
|
||||
|
||||
// Contract surface only (export-convergence rule: cross-package consumers
|
||||
// keep a symbol exported; test-only/package-internal symbols live off /src).
|
||||
// LayoutService: the ctx.layout service class (consumers type against it).
|
||||
// ILayout: the ctx.layout face consumers and test fakes type against.
|
||||
// OwnerShare contracts below are the render-side halves registrants compose
|
||||
// against; the frame components and the store factory are package-internal.
|
||||
export { LayoutService } from './service.ts'
|
||||
export type { ILayout } from './service.ts'
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
layout: LayoutService
|
||||
/** The outward face only; the concrete service stays inside this plugin. */
|
||||
layout: import('./service.ts').ILayout
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,8 +14,23 @@ import type { createLayoutStore } from './stores.ts'
|
||||
/** The layout store's bound action set (framework-baked, draft params peeled). */
|
||||
export type PanelActions = BoundActions<ReturnType<typeof createLayoutStore>>
|
||||
|
||||
/**
|
||||
* The outward layout face (`ctx.layout`): the panel transitions other
|
||||
* plugins may trigger — and exactly what a test fake must supply. The
|
||||
* attachPanels wiring hook stays on the concrete class (root-entry assembly
|
||||
* only).
|
||||
*/
|
||||
export interface ILayout {
|
||||
/** Toggle the sidebar panel (closed ⟷ contract default width). */
|
||||
toggleSidebar(): void
|
||||
/** Open the details panel (no-op when already open). */
|
||||
openDetails(): void
|
||||
/** Close the details panel. */
|
||||
closeDetails(): void
|
||||
}
|
||||
|
||||
/** Cross-plugin panel-action face (ctx.layout). */
|
||||
export class LayoutService {
|
||||
export class LayoutService implements ILayout {
|
||||
#panels: PanelActions | undefined
|
||||
|
||||
/**
|
||||
|
||||
@@ -47,6 +47,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-test-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-layout": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`sidebar shell snapshots > renders the collapsed rail after the crossfade settles, in place 1`] = `
|
||||
<div
|
||||
data-slot="sidebar"
|
||||
>
|
||||
<div
|
||||
class="root collapsed railIn"
|
||||
style=""
|
||||
>
|
||||
<div
|
||||
class="logoRow"
|
||||
>
|
||||
<button
|
||||
aria-label="Open sidebar"
|
||||
class="iconButton toggle"
|
||||
type="button"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class="railFish"
|
||||
data-content="965fe321"
|
||||
fill="none"
|
||||
height="17.6580310880829"
|
||||
viewBox="0 0 23.16 17.04"
|
||||
width="24"
|
||||
/>
|
||||
<svg
|
||||
class="panelIcon"
|
||||
data-content="35f95b0c"
|
||||
fill="none"
|
||||
height="18"
|
||||
viewBox="0 0 16 16"
|
||||
width="18"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
aria-label="New session"
|
||||
class="newSession"
|
||||
type="button"
|
||||
>
|
||||
<svg
|
||||
data-content="c0ce4dcc"
|
||||
fill="none"
|
||||
height="18"
|
||||
viewBox="0 0 16 16"
|
||||
width="18"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
/>
|
||||
</button>
|
||||
<div
|
||||
class="regionArea"
|
||||
/>
|
||||
<div
|
||||
class="footArea"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`sidebar shell snapshots > renders the expanded column (wordmark, capsule, empty holes) 1`] = `
|
||||
<div
|
||||
data-slot="sidebar"
|
||||
>
|
||||
<div
|
||||
class="root"
|
||||
style="width: 300px;"
|
||||
>
|
||||
<div
|
||||
class="logoRow"
|
||||
>
|
||||
<button
|
||||
aria-label="New session"
|
||||
class="brand wide"
|
||||
type="button"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
data-content="951274b9"
|
||||
fill="none"
|
||||
height="24"
|
||||
viewBox="0 0 182 24"
|
||||
width="182"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
aria-label="Collapse sidebar"
|
||||
class="iconButton toggle"
|
||||
type="button"
|
||||
>
|
||||
<svg
|
||||
class="panelIcon"
|
||||
data-content="35f95b0c"
|
||||
fill="none"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
width="16"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
aria-label="New session"
|
||||
class="newSession"
|
||||
type="button"
|
||||
>
|
||||
<svg
|
||||
data-content="c0ce4dcc"
|
||||
fill="none"
|
||||
height="14"
|
||||
viewBox="0 0 16 16"
|
||||
width="14"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
/>
|
||||
<span
|
||||
class="newSessionLabel wide"
|
||||
>
|
||||
New Session
|
||||
</span>
|
||||
</button>
|
||||
<div
|
||||
class="regionArea"
|
||||
/>
|
||||
<div
|
||||
class="footArea"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
51
packages/client/ui-sidebar/tests/sidebar-snapshot.spec.tsx
Normal file
51
packages/client/ui-sidebar/tests/sidebar-snapshot.spec.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Local DOM snapshots of the sidebar shell through the real assembly path:
|
||||
* SlotTestRuntime mounts the package apply on its own fiber, the auto frame
|
||||
* supplies the layout's owner share at the render site, and the snapshot
|
||||
* captures exactly the 'sidebar' slot's output (CSS-module class names
|
||||
* folded to their semantic locals by the runtime's serializer). The child
|
||||
* holes (sidebar.workspaces / sidebar.settings) have no registrant here, so
|
||||
* the snapshots pin the shell chrome itself.
|
||||
*/
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, waitFor } from '@testing-library/react'
|
||||
import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { apply, inject } from '@deepseek-ai/dsh-client-ui-sidebar/client'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
async function bench() {
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
runtime.provide('layout', { toggleSidebar: vi.fn() })
|
||||
await runtime.declare({ 'sidebar': { kind: 'single', scope: 'root' } })
|
||||
await runtime.mount({ inject: [...inject], apply })
|
||||
return runtime
|
||||
}
|
||||
|
||||
describe('sidebar shell snapshots', () => {
|
||||
it('renders the expanded column (wordmark, capsule, empty holes)', async () => {
|
||||
const runtime = await bench()
|
||||
const slot = runtime.renderSlot('sidebar', { collapsed: false, width: 300 })
|
||||
// Wordmark + capsule both start a session in the expanded state.
|
||||
expect(slot.view.getAllByRole('button', { name: 'New session' })).toHaveLength(2)
|
||||
expect(slot.container).toMatchSnapshot()
|
||||
await runtime.dispose()
|
||||
})
|
||||
|
||||
it('renders the collapsed rail after the crossfade settles, in place', async () => {
|
||||
const runtime = await bench()
|
||||
const slot = runtime.renderSlot('sidebar', { collapsed: false, width: 300 })
|
||||
const shell = slot.container.firstElementChild
|
||||
slot.update({ collapsed: true, width: 56 })
|
||||
// The wide content (wordmark shortcut) unmounts at the 150ms settle;
|
||||
// only the rail's capsule remains a New-session button.
|
||||
await waitFor(() => {
|
||||
expect(slot.view.getAllByRole('button', { name: 'New session' })).toHaveLength(1)
|
||||
})
|
||||
expect(slot.container).toMatchSnapshot()
|
||||
// Same tree position: the owner flip re-rendered the shell in place.
|
||||
expect(slot.container.firstElementChild).toBe(shell)
|
||||
await runtime.dispose()
|
||||
})
|
||||
})
|
||||
@@ -24,7 +24,8 @@ export type { SlashServiceContract } from './contract.ts'
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
slash: SlashService
|
||||
/** The outward face only; the concrete service stays inside this plugin. */
|
||||
slash: import('./contract.ts').SlashServiceContract
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
*/
|
||||
import { Service } from 'cordis'
|
||||
import type { Context } from 'cordis'
|
||||
import type { ClientContext, SessionId, SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ClientContext, ISessions, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SlashSource } from '../types.ts'
|
||||
import { SlashController } from './controller.ts'
|
||||
import type { SlashServiceContract } from './contract.ts'
|
||||
@@ -99,7 +99,7 @@ export class SlashService extends Service implements SlashServiceContract {
|
||||
return controller
|
||||
}
|
||||
|
||||
private sessions(): SessionsService {
|
||||
private sessions(): ISessions {
|
||||
const sessions = this.ctx.get('sessions')
|
||||
if (sessions === undefined) throw new Error('ui-slash: sessions service unavailable')
|
||||
return sessions
|
||||
|
||||
@@ -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",
|
||||
|
||||
61
packages/client/web/tests/app-shell.spec.tsx
Normal file
61
packages/client/web/tests/app-shell.spec.tsx
Normal file
@@ -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' }, () => <div data-testid="root-probe" />)
|
||||
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' }, () => <div data-testid="root-probe" />)
|
||||
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')
|
||||
})
|
||||
})
|
||||
65
packages/client/web/tests/app.spec.tsx
Normal file
65
packages/client/web/tests/app.spec.tsx
Normal file
@@ -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({}, () => <div data-testid="frame" />)
|
||||
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')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user