Merge remote-tracking branch 'github/master' into xtr/trajectory-inspection-ui

# Conflicts:
#	packages/client/runtime/src/client/sessions/session.ts
#	packages/client/ui-conversation/README.i18n.yaml
#	packages/client/ui-conversation/README.md
#	packages/client/ui-conversation/README.zh.md
This commit is contained in:
_Kerman
2026-07-29 09:48:24 +08:00
356 files changed
+8801 -1511

No files matched your search

@@ -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
View 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()
}
}
@@ -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 */
@@ -0,0 +1,398 @@
/** 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`)
}
/**
* Fail-loud stub; supply `loadAllHistory` on the fixture's session face to exercise it.
* @returns never — always throws.
*/
loadAllHistory(): never {
throw new Error(`test session "${this.sessionId}": loadAllHistory 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
}
}
@@ -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)
}
@@ -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
}
}