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 changed files with 8801 additions and 1511 deletions

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/test-runtime/README.md
README.md: 883d71224139dc409229fdfb35362d040e810cc7
README.zh.md: a3daf112940b03b585d44bc5fd1317e43ebd35fe

View File

@@ -0,0 +1,24 @@
# @deepseek-ai/dsh-client-test-runtime
English | [中文](README.zh.md)
jsdom slot test runtime for client feature specs: a real Cordis `Context`, the production `SlotsService` and web-react renderer, assembled around typed session/workspace doubles. Feature suites exercise declaration, registration, scope, store, inject, rendering, updates, and disposal without hand-building the machinery per suite — and without a second implementation of any production logic.
The doubles implement the same outward faces features receive through ctx (`TestSessions implements ISessions`, `TestWorkspaces implements IWorkspaces`; each fixture session is a `FixtureSession implements SessionFace`), so a production face change breaks the bench at compile time instead of silently drifting. Provide-bundle materialization runs the production `SessionProvideChannel` — the one implementation shared with `SessionsService`. Fixtures feed plain data: list rows, conversation snapshots (immer-patched via `updateSnapshot`), projection values, and `ISession`-typed behavior stubs that fail loud when a spec calls an unstubbed verb. The typed `provide()` constrains fakes for declared service names to `Partial` of that service's outward face.
Local DOM snapshots: `declare(children)` registers an auto frame whose per-key `<div data-slot>` wrappers are snapshot roots; `renderSlot(key, owner)` returns the slot-local view (container, scoped Testing Library queries, in-place `update(owner)`); a registered snapshot serializer folds CSS-module class hashes (`_frame_a1b2c3``frame`) to keep `.snap` files structural and collapses `<svg>` internals to a `data-content` fingerprint. Suites needing a custom page frame use `root.declare(children, Frame)` instead; `mount(plugin)` runs a real fiber with fail-loud service prechecks, and `dispose()` tears down views, feature fibers, minted scopes, and persisted store state on one axis.
Not part of the product plugin graph (no `dshClient`); feature packages depend on it in `devDependencies` only.
## Model Experience
None, as this package is browser-side test infrastructure; nothing here reaches a model request.
#### KV Cache effect
None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **Consumed through repository source aliases only.** Specs resolve the package through tsconfig `paths` to `src`; the built `lib/` artifact re-exports `@deepseek-ai/dsh-client-runtime/client`, whose bundle is a browser loader script with no Node ESM exports, so `lib/index.js` is not importable under plain Node. Acceptable while every consumer is an in-repo Vitest suite; a Node-compatible runtime entry is deferred until an out-of-repo consumer exists.
- **Conversation snapshots are fixture data, not replayed history.** `updateSnapshot` writes the snapshot store directly; the wire-to-snapshot computation stays covered by the runtime package's own tests and the replay e2e. A fixture can therefore express states the production fold would never produce.

View File

@@ -0,0 +1,24 @@
# @deepseek-ai/dsh-client-test-runtime
[English](README.md) | 中文
面向 client feature 测试的 jsdom slot 测试运行时:真实 Cordis `Context`、生产 `SlotsService` 与 web-react 渲染器,围绕带类型的 session/workspace 测试替身组装。feature 套件无需逐套件手搭机器即可测遍声明、注册、scope、store、inject、渲染、更新与销毁——且不存在任何生产逻辑的第二份实现。
替身实现的正是 feature 经 ctx 拿到的对外面(`TestSessions implements ISessions``TestWorkspaces implements IWorkspaces`;每个 fixture session 是 `FixtureSession implements SessionFace`生产面一旦改形测试台在编译期即断而非静默漂移。provide bundle 材料化直接运行生产 `SessionProvideChannel`——与 `SessionsService` 共用同一份实现。fixture 灌入的是普通数据:列表行、会话快照(经 `updateSnapshot` 以 immer 补丁改写、projection 值,以及按 `ISession` 取型的行为桩——spec 调用未打桩的动词时报错自明。带类型的 `provide()` 将已声明服务名的 fake 约束为该服务对外面的 `Partial` 子集。
局部 DOM 快照:`declare(children)` 注册自动 frame逐 key 的 `<div data-slot>` 包裹层即快照根;`renderSlot(key, owner)` 返回该 slot 的局部视图container、限定范围的 Testing Library 查询、原位 `update(owner)`);注册的快照序列化器把 CSS-module 哈希类名折回语义名(`_frame_a1b2c3``frame`)保持 `.snap` 只含结构,并把 `<svg>` 内部折叠为 `data-content` 指纹。需要自定义页面 frame 的套件改用 `root.declare(children, Frame)``mount(plugin)` 在真实 fiber 上运行并对缺失服务先行报错;`dispose()` 沿单一轴拆除视图、feature fiber、已铸 scope 与持久化 store 状态。
不属于产品插件图(无 `dshClient`feature 包仅以 `devDependencies` 依赖之。
## Model Experience
无;本包是浏览器侧测试基础设施,无一物到达模型请求。
#### KV Cache effect
无;本包既不组装也不发送 provider 请求。
## Known Limitations and Deferred Work
- **仅可经仓内源码别名消费。** spec 通过 tsconfig `paths` 解析到 `src`;构建产物 `lib/` 再导出 `@deepseek-ai/dsh-client-runtime/client`,而该 bundle 是无 Node ESM 导出的浏览器 loader 脚本,故 `lib/index.js` 在纯 Node 下不可导入。当前所有消费方都是仓内 Vitest 套件可接受Node 兼容的运行时入口待出现仓外消费方再补。
- **会话快照是 fixture 数据,不是重放历史。** `updateSnapshot` 直写快照 storewire 到快照的运算仍由 runtime 包自身测试与 replay e2e 把守。因此 fixture 可以表达生产折叠永不产出的状态。

View File

@@ -0,0 +1,54 @@
{
"name": "@deepseek-ai/dsh-client-test-runtime",
"description": "jsdom slot test runtime: real Cordis Context + SlotsService + web-react renderer with test-owned session/workspace doubles for feature specs",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"license": "BSD-3-Clause",
"dependencies": {
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.2",
"vitest": "^4.1.8"
},
"peerDependencies": {
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
"@deepseek-ai/dsh-client-web-react": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"cordis": "^4.0.0-rc.7",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-client-web-react": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@types/react": "~18.3.1",
"@types/react-dom": "~18.3.0",
"cordis": "^4.0.0-rc.7",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
]
}

View File

@@ -0,0 +1,82 @@
/** Session/workspace fixture shapes and snapshot defaults for the test runtime. */
import type {
ConversationSnapshot, ISession, SessionId, SessionSummary, WorkspaceListState,
} from '@deepseek-ai/dsh-client-runtime/client'
/**
* Fixture overrides for the session behavior face: any subset of the
* production ISession verbs (typed against it, so a face change surfaces
* here at compile time), plus extra members feature-specific casts consume.
* The open Record tail means a misnamed EXTRA member is not caught by the
* compiler (it grafts as dead weight); the ISession verbs stay safe — a
* misnamed verb leaves the fail-loud stub in place, which names itself at
* the first call.
*/
export type SessionBehaviorOverrides = Partial<ISession> & Record<string, unknown>
/**
* act-wrapped mutation runner shared by every runtime object: public mutators
* funnel through it so tests never handle SlotCore microtask batching or
* React act themselves.
*/
export type Stabilizer = (fn: () => void | Promise<void>) => Promise<void>
/**
* Session fixture accepted by {@link TestSessions.add}: identity plus optional
* snapshot/list-row overrides and the session behavior face the feature under
* test actually calls (kept open — the runtime never fakes methods a test did
* not supply, so an unstubbed call fails loud at the call site).
*/
export interface SessionFixture {
id: string
/** Overrides merged over {@link conversationSnapshot} (sessionId comes from `id`). */
snapshot?: Partial<Omit<ConversationSnapshot, 'sessionId'>>
/** List-row overrides merged over the defaults derived from `id`. */
summary?: Partial<Omit<SessionSummary, 'id'>>
/** Session behavior face: exactly the methods the feature under test calls (ISession subset + extras). */
session?: SessionBehaviorOverrides
}
/**
* A complete quiescent conversation snapshot (open window, no traffic).
* @param sessionId - owning session id.
* @returns the snapshot; spread fixture overrides on top.
*/
export function conversationSnapshot(sessionId: SessionId): ConversationSnapshot {
return {
sessionId,
nodes: [],
foldDegraded: false,
partial: null,
runningCalls: [],
codeDispatches: new Map(),
pending: [],
queue: [],
running: false,
composerPhase: 'active',
removed: false,
openState: 'open',
openError: null,
hasMore: false,
loadingOlder: false,
promptError: null,
blank: false,
lastAgentError: null,
}
}
/**
* A ready workspace list with no workspaces (the shape WorkspacesService
* projects after both baselines land).
* @returns the initial state of the test workspaces store.
*/
export function workspaceListState(): WorkspaceListState {
return {
items: [],
state: 'idle',
phase: 'ready',
error: null,
baselinesReady: true,
recentWorkspaceId: undefined,
}
}

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()
}
}

View File

@@ -0,0 +1,32 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-client-test-runtime`.
* @module @deepseek-ai/dsh-client-test-runtime/invariant
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-client-test-runtime'
/** Cordis companion plugin name. */
export const name = 'client-test-runtime-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: this test-support package owns no production event
* stream or mutable data — it assembles the runtime SlotsService and renderer
* (whose packages own their invariants) around test doubles; its own behavior
* is exercised by its package tests.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */

View File

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

View File

@@ -0,0 +1,89 @@
/**
* DOM snapshot hygiene: a vitest snapshot serializer that keeps `.snap`
* files structural. Two normalizations, both on a clone (the live DOM is
* untouched, so class/tag queries keep working):
*
* - CSS-module scoped class names (`_frame_334d2d`, this repo's
* `_[local]_[hash]` shape) fold back to their semantic local (`frame`), so
* CSS edits do not churn snapshots.
* - `<svg>` internals collapse to a `data-content` fingerprint on the svg
* element: path geometry is print noise, but the fingerprint still flips
* when an icon's artwork actually changes.
*/
import { expect } from 'vitest'
import type { SnapshotSerializer } from 'vitest'
/** One scoped class token: `_<local>_<hash>` (local may itself contain underscores). */
const SCOPED_CLASS = /^_(.+)_[a-z0-9]+$/
/** Fold scoped tokens in one class attribute value; foreign tokens pass through. */
function normalizeClassValue(value: string): string {
return value
.split(/\s+/)
.filter(token => token !== '')
.map(token => token.replace(SCOPED_CLASS, '$1'))
.join(' ')
}
/** FNV-1a 32-bit over the svg markup: deterministic, dependency-free fingerprint. */
function fingerprint(markup: string): string {
let hash = 0x811c9dc5
for (let i = 0; i < markup.length; i++) {
hash ^= markup.charCodeAt(i)
hash = Math.imul(hash, 0x01000193)
}
return (hash >>> 0).toString(16).padStart(8, '0')
}
/** svg elements of a subtree, the root included when it is one. */
function svgsOf(root: Element): Element[] {
const svgs: Element[] = [...root.querySelectorAll('svg')]
if (root.tagName.toLowerCase() === 'svg') svgs.unshift(root)
return svgs
}
/** Whether serializing this subtree needs a normalized clone. */
function needsNormalization(root: Element): boolean {
const scoped = [root, ...root.querySelectorAll('[class]')].some((el) => {
const value = el.getAttribute('class')
return value !== null && value.split(/\s+/).some(token => SCOPED_CLASS.test(token))
})
return scoped || svgsOf(root).some(svg => svg.childNodes.length > 0)
}
/**
* The serializer plugin. Matches DOM elements whose subtree carries a scoped
* class or svg internals; serializes a normalized clone, which no longer
* matches, so printing falls through to the built-in DOM element serializer.
*/
export const domSnapshotSerializer: SnapshotSerializer = {
test(value: unknown): boolean {
return typeof Element !== 'undefined' && value instanceof Element && needsNormalization(value)
},
serialize(value, config, indentation, depth, refs, printer): string {
const clone = (value as Element).cloneNode(true) as Element
for (const el of [clone, ...clone.querySelectorAll('[class]')]) {
const raw = el.getAttribute('class')
if (raw !== null) el.setAttribute('class', normalizeClassValue(raw))
}
for (const svg of svgsOf(clone)) {
if (svg.childNodes.length === 0) continue
svg.setAttribute('data-content', fingerprint(svg.innerHTML))
svg.replaceChildren()
}
return printer(clone, config, indentation, depth, refs)
},
}
let registered = false
/**
* Register {@link domSnapshotSerializer} with vitest's expect (idempotent).
* SlotTestRuntime.create() calls this; specs that snapshot DOM outside the
* runtime import and call it themselves.
*/
export function registerDomSnapshotSerializer(): void {
if (registered) return
registered = true
expect.addSnapshotSerializer(domSnapshotSerializer)
}

View File

@@ -0,0 +1,147 @@
/** Test-owned workspaces face: the renderer standard-kit observable plus recorded actions. */
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type {
IWorkspaces, SessionId, SnapshotStore, WorkspaceId, WorkspaceListState, WorkspaceView,
} from '@deepseek-ai/dsh-client-runtime/client'
import { workspaceListState } from './fixtures.ts'
import type { Stabilizer } from './fixtures.ts'
/**
* Workspaces test double. Implements the same IWorkspaces face features
* receive as `ctx.workspaces`, so a production face change breaks this
* double at compile time. Every action records into {@link
* TestWorkspaces.calls}; defaults are inert echoes — feature tests needing
* richer behavior replace them via {@link TestWorkspaces.stub}.
*/
export class TestWorkspaces implements IWorkspaces {
/** The useWorkspaces standard feed. */
readonly list: SnapshotStore<WorkspaceListState>
/** Calls observed on the action face, newest last. */
readonly calls: { method: string; args: unknown[] }[] = []
/** Replaceable action seat: feature tests may stub richer behavior. */
private readonly stubs = new Map<string, (...args: unknown[]) => unknown>()
/**
* @param stabilize - the owning runtime's act wrapper.
*/
constructor(private readonly stabilize: Stabilizer) {
this.list = createSnapshotStore<WorkspaceListState>(workspaceListState())
}
/**
* Update the workspace list state through an immer draft.
* @param mutate - draft mutator.
*/
async update(mutate: (draft: WorkspaceListState) => void): Promise<void> {
await this.stabilize(() => { this.list.update(mutate) })
}
/**
* Replace an action's behavior (the recorded call is still appended first).
* @param method - action name (e.g. 'connectWorkspace').
* @param impl - replacement behavior.
*/
stub(method: string, impl: (...args: unknown[]) => unknown): void {
this.stubs.set(method, impl)
}
/**
* Connect a workspace to its reusable/new blank session (recorded). The
* default resolves the workspace id back as the session id; stub for
* cross-session flows.
* @param workspaceId - target workspace.
* @returns the connected session id.
*/
async connectWorkspace(workspaceId: WorkspaceId): Promise<SessionId> {
this.calls.push({ method: 'connectWorkspace', args: [workspaceId] })
const stub = this.stubs.get('connectWorkspace')
if (stub !== undefined) return await (stub(workspaceId) as Promise<SessionId>)
return `session-of-${workspaceId}` as SessionId
}
/**
* New-session flow (recorded; stubbed behavior runs when installed).
* @param workspaceId - optional explicit workspace target.
*/
startSession(workspaceId?: WorkspaceId): void {
this.calls.push({ method: 'startSession', args: [workspaceId] })
this.stubs.get('startSession')?.(workspaceId)
}
/**
* Create a Workspace (recorded). The default echoes a view derived from
* the input; stub for failure or list-coupled flows.
* @param input - exactly one Host create spelling.
* @returns the created Workspace view.
*/
async create(input: { name: string } | { path: string }): Promise<WorkspaceView> {
this.calls.push({ method: 'create', args: [input] })
const stub = this.stubs.get('create')
if (stub !== undefined) return await (stub(input) as Promise<WorkspaceView>)
const title = 'name' in input ? input.name : input.path
return {
workspaceId: `ws-${title}` as WorkspaceId,
title,
path: 'path' in input ? input.path : `/${input.name}`,
sessionIds: [],
} as unknown as WorkspaceView
}
/**
* Open a path with the host OS default application (recorded; default no-op).
* @param path - host-resolvable path.
*/
async openPath(path: string): Promise<void> {
this.calls.push({ method: 'openPath', args: [path] })
await (this.stubs.get('openPath')?.(path) as Promise<void> | undefined)
}
/**
* Directory picker (recorded). The default cancels (null); stub to select.
* @returns the picked path, or null.
*/
async pickDirectory(): Promise<string | null> {
this.calls.push({ method: 'pickDirectory', args: [] })
const stub = this.stubs.get('pickDirectory')
if (stub !== undefined) return await (stub() as Promise<string | null>)
return null
}
/**
* Rename a Workspace (recorded). The default echoes a minimal view.
* @param workspaceId - target workspace.
* @param title - new title.
* @returns the updated view.
*/
async rename(workspaceId: WorkspaceId, title: string): Promise<WorkspaceView> {
this.calls.push({ method: 'rename', args: [workspaceId, title] })
const stub = this.stubs.get('rename')
if (stub !== undefined) return await (stub(workspaceId, title) as Promise<WorkspaceView>)
return { workspaceId, title, path: `/${title}`, sessionIds: [] } as unknown as WorkspaceView
}
/**
* Delete a Workspace (recorded; default no-op).
* @param workspaceId - target workspace.
*/
async delete(workspaceId: WorkspaceId): Promise<void> {
this.calls.push({ method: 'delete', args: [workspaceId] })
await (this.stubs.get('delete')?.(workspaceId) as Promise<void> | undefined)
}
/**
* Move an accounted session (recorded). The default echoes a minimal view.
* @param workspaceId - target workspace.
* @param sessionId - session to move.
* @param beforeSessionId - anchor; omitted appends.
* @returns the updated view.
*/
async insertSessionBefore(workspaceId: WorkspaceId, sessionId: SessionId, beforeSessionId?: SessionId): Promise<WorkspaceView> {
this.calls.push({ method: 'insertSessionBefore', args: [workspaceId, sessionId, beforeSessionId] })
const stub = this.stubs.get('insertSessionBefore')
if (stub !== undefined) return await (stub(workspaceId, sessionId, beforeSessionId) as Promise<WorkspaceView>)
return { workspaceId, title: '', path: '', sessionIds: [sessionId] } as unknown as WorkspaceView
}
}

View File

@@ -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>
`;

View File

@@ -0,0 +1,12 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import * as TestRuntimeInvariant from '@deepseek-ai/dsh-client-test-runtime/invariant'
import InvariantService from '@deepseek-ai/dsh-invariants'
describe('invariant companion', () => {
it('registers under the package name with an empty installer', async () => {
const ctx = new Context()
await ctx.plugin(InvariantService, { enabled: true })
await expect(ctx.plugin(TestRuntimeInvariant).await()).resolves.toBeDefined()
})
})

View File

@@ -0,0 +1,547 @@
// @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/)
expect(() => bare.loadAllHistory()).toThrow(/loadAllHistory 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()
})
})

View File

@@ -0,0 +1,27 @@
{
"extends": "../../../tsconfig.base.client.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cordis"
},
{
"path": "../ui-slots"
},
{
"path": "../web-react"
},
{
"path": "../runtime"
},
{
"path": "../../support/invariants"
}
]
}