Quellcode durchsuchen

feat(client): add the dsh-client-test-runtime package

A jsdom slot test runtime for feature specs: a real Cordis Context, the
production SlotsService and web-react renderer, and typed session/workspace
doubles (TestSessions implements ISessions with FixtureSession sessions;
TestWorkspaces implements IWorkspaces), so the compiler flags fixture drift
when a production face changes. Fixtures feed plain data: list rows,
conversation snapshots, and ISession-typed behavior stubs; provide-bundle
materialization runs the shared SessionProvideChannel.

DOM snapshot support: declare()/renderSlot() mount a single slot inside a
data-slot wrapper for local .snap capture, and a snapshot serializer folds
CSS-module class hashes to their semantic locals and collapses svg internals
to a content fingerprint. The typed provide() constrains declared-service
fakes to Partial of the service's outward face.
imccyu vor 1 Monat
Ursprung
Commit
3f7a8c6f8e

+ 1 - 0
docs/config-catalog.md

@@ -2230,6 +2230,7 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them.
 - `@deepseek-ai/dsh-agent-loop-testkit` ([`packages/support/agent-loop-testkit/src/index.ts`](../packages/support/agent-loop-testkit/src/index.ts))
 - `@deepseek-ai/dsh-app-boot` ([`packages/ui/app-boot/src/index.ts`](../packages/ui/app-boot/src/index.ts))
 - `@deepseek-ai/dsh-brand` ([`packages/util/brand/src/index.ts`](../packages/util/brand/src/index.ts))
+- `@deepseek-ai/dsh-client-test-runtime` ([`packages/client/test-runtime/src/index.ts`](../packages/client/test-runtime/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-primitives` ([`packages/client/ui-primitives/src/index.ts`](../packages/client/ui-primitives/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-slots` ([`packages/client/ui-slots/src/index.ts`](../packages/client/ui-slots/src/index.ts))
 - `@deepseek-ai/dsh-client-web` ([`packages/client/web/src/index.ts`](../packages/client/web/src/index.ts))

+ 6 - 0
docs/module-graph.md

@@ -141,6 +141,7 @@ flowchart TD
     pkg_client_locale["client-locale"]
     pkg_client_modules["client-modules"]
     pkg_client_runtime["client-runtime"]
+    pkg_client_test_runtime["client-test-runtime"]
     pkg_client_ui_command["client-ui-command"]
     pkg_client_ui_conversation["client-ui-conversation"]
     pkg_client_ui_layout["client-ui-layout"]
@@ -276,6 +277,10 @@ flowchart TD
   pkg_client_locale --> pkg_client_ui_primitives
   pkg_client_locale --> pkg_client_ui_slots
   pkg_client_locale --> pkg_invariants
+  pkg_client_test_runtime --> pkg_client_runtime
+  pkg_client_test_runtime --> pkg_client_ui_slots
+  pkg_client_test_runtime --> pkg_client_web_react
+  pkg_client_test_runtime --> pkg_invariants
   pkg_client_ui_models --> pkg_client_runtime
   pkg_client_ui_models --> pkg_client_ui_slots
   pkg_client_ui_models --> pkg_invariants
@@ -946,6 +951,7 @@ flowchart TD
 | [`client-connection`](../packages/client/connection) | `client` | [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) |
 | [`client-hmr`](../packages/client/hmr) | `client` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) |
 | [`client-locale`](../packages/client/locale) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
+| [`client-test-runtime`](../packages/client/test-runtime) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) |
 | [`client-ui-models`](../packages/client/ui-models) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
 | [`client-ui-settings`](../packages/client/ui-settings) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
 | [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |

+ 6 - 0
packages/client/test-runtime/README.i18n.yaml

@@ -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 - 0
packages/client/test-runtime/README.md

@@ -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 - 0
packages/client/test-runtime/README.zh.md

@@ -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 - 0
packages/client/test-runtime/package.json

@@ -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 - 0
packages/client/test-runtime/src/fixtures.ts

@@ -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 - 0
packages/client/test-runtime/src/index.ts

@@ -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 - 0
packages/client/test-runtime/src/invariant.ts

@@ -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 - 0
packages/client/test-runtime/src/sessions.ts

@@ -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 - 0
packages/client/test-runtime/src/snapshot.ts

@@ -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 - 0
packages/client/test-runtime/src/workspaces.ts

@@ -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
+  }
+}

+ 37 - 0
packages/client/test-runtime/tests/__snapshots__/runtime.spec.tsx.snap

@@ -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 - 0
packages/client/test-runtime/tests/invariant.spec.ts

@@ -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 - 0
packages/client/test-runtime/tests/runtime.spec.tsx

@@ -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 - 0
packages/client/test-runtime/tsconfig.json

@@ -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"
+    }
+  ]
+}

+ 76 - 0
pnpm-lock.yaml

@@ -924,6 +924,46 @@ importers:
         specifier: ^4.0.0-rc.7
         version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
 
+  packages/client/test-runtime:
+    dependencies:
+      '@testing-library/dom':
+        specifier: ^10.4.1
+        version: 10.4.1
+      '@testing-library/react':
+        specifier: ^16.3.2
+        version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+      vitest:
+        specifier: ^4.1.8
+        version: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))
+    devDependencies:
+      '@deepseek-ai/dsh-client-runtime':
+        specifier: workspace:^
+        version: link:../runtime
+      '@deepseek-ai/dsh-client-ui-slots':
+        specifier: workspace:^
+        version: link:../ui-slots
+      '@deepseek-ai/dsh-client-web-react':
+        specifier: workspace:^
+        version: link:../web-react
+      '@deepseek-ai/dsh-invariants':
+        specifier: workspace:^
+        version: link:../../support/invariants
+      '@types/react':
+        specifier: ~18.3.1
+        version: 18.3.31
+      '@types/react-dom':
+        specifier: ~18.3.0
+        version: 18.3.7(@types/react@18.3.31)
+      cordis:
+        specifier: ^4.0.0-rc.7
+        version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
+      react:
+        specifier: ^18.2.0
+        version: 18.3.1
+      react-dom:
+        specifier: ^18.2.0
+        version: 18.3.1(react@18.3.1)
+
   packages/client/ui-command:
     dependencies:
       clsx:
@@ -1250,6 +1290,9 @@ importers:
       '@deepseek-ai/dsh-client-runtime':
         specifier: workspace:^
         version: link:../runtime
+      '@deepseek-ai/dsh-client-test-runtime':
+        specifier: workspace:^
+        version: link:../test-runtime
       '@deepseek-ai/dsh-client-ui-layout':
         specifier: workspace:^
         version: link:../ui-layout
@@ -1467,6 +1510,9 @@ importers:
       '@deepseek-ai/dsh-client-runtime':
         specifier: workspace:^
         version: link:../runtime
+      '@deepseek-ai/dsh-client-test-runtime':
+        specifier: workspace:^
+        version: link:../test-runtime
       '@deepseek-ai/dsh-invariants':
         specifier: workspace:^
         version: link:../../support/invariants
@@ -15933,6 +15979,36 @@ snapshots:
     transitivePeerDependencies:
       - msw
 
+  vitest@4.1.8(@opentelemetry/api@1.9.1)(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)):
+    dependencies:
+      '@vitest/expect': 4.1.8
+      '@vitest/mocker': 4.1.8(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))
+      '@vitest/pretty-format': 4.1.8
+      '@vitest/runner': 4.1.8
+      '@vitest/snapshot': 4.1.8
+      '@vitest/spy': 4.1.8
+      '@vitest/utils': 4.1.8
+      es-module-lexer: 2.1.0
+      expect-type: 1.3.0
+      magic-string: 0.30.21
+      obug: 2.1.3
+      pathe: 2.0.3
+      picomatch: 4.0.4
+      std-env: 4.1.0
+      tinybench: 2.9.0
+      tinyexec: 1.2.4
+      tinyglobby: 0.2.17
+      tinyrainbow: 3.1.0
+      vite: 8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)
+      why-is-node-running: 2.3.0
+    optionalDependencies:
+      '@opentelemetry/api': 1.9.1
+      '@types/node': 25.9.3
+      '@vitest/coverage-v8': 4.1.8(vitest@4.1.8)
+      jsdom: 29.1.1
+    transitivePeerDependencies:
+      - msw
+
   vscode-jsonrpc@5.0.1: {}
 
   vscode-jsonrpc@9.0.1: {}

+ 1 - 0
scripts/verify-package-readme-model-experience.ts

@@ -47,6 +47,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
   'packages/code-runtime/code-runtime-worker': { kind: 'indirect', reason: 'The worker backend delegates model rendering to Code Mode in dsh-tools.' },
   'packages/client/hmr': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
   'packages/client/modules': { kind: 'none', reason: 'Browser-side module-loading kernel machinery; registers no model surface.' },
+  'packages/client/test-runtime': { kind: 'none', reason: 'Browser-side test infrastructure (jsdom bench); registers no model surface.' },
   'packages/client/ui-slots': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
   'packages/client/ui-primitives': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
   'packages/client/web-react': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },

+ 1 - 0
tsconfig.base.json

@@ -118,6 +118,7 @@
       "@deepseek-ai/dsh-client-modules": ["./packages/client/modules/src"],
       "@deepseek-ai/dsh-client-runtime": ["./packages/client/runtime/src"],
       "@deepseek-ai/dsh-client-runtime/client": ["./packages/client/runtime/src/client"],
+      "@deepseek-ai/dsh-client-test-runtime": ["./packages/client/test-runtime/src"],
       "@deepseek-ai/dsh-client-ui-layout": ["./packages/client/ui-layout/src"],
       "@deepseek-ai/dsh-client-ui-sidebar": ["./packages/client/ui-sidebar/src"],
       "@deepseek-ai/dsh-client-ui-conversation": ["./packages/client/ui-conversation/src"],

+ 1 - 0
tsconfig.client.json

@@ -34,6 +34,7 @@
     { "path": "./packages/client/hmr" },
     { "path": "./packages/client/connection" },
     { "path": "./packages/client/runtime" },
+    { "path": "./packages/client/test-runtime" },
     { "path": "./packages/client/ui-layout" },
     { "path": "./packages/client/ui-sidebar" },
     { "path": "./packages/client/ui-conversation" },