description: "面向浏览器功能测试的 jsdom slot 测试运行时,供测试作者针对生产机制检验 slot、存储与渲染。"
English | 中文
SlotTestRuntime.create() 让 Vitest 套件在 jsdom 中驱动生产 slot、store、带类型的 Session 与 Workspace fixture,并对局部 DOM 断言。面向插件激活、重载、重连与清理的测试,createClientTest 使用具名端点 Remote mock 启动 web profile 的 bundle roster,无需业务 Host。缺失服务与未打桩调用会明确失败。整机 fixture 拥有启动和销毁,局部 runtime 提供幂等销毁。通过 devDependencies 将本包用于客户端测试;它不是产品插件。
本包让浏览器功能测试拥有可挂载的真实运行时:创建测试台,声明你的功能所占用的 slot,挂载功能插件,渲染一个 slot,在局部视图上断言,然后 dispose(资源释放)——全程不存在生产逻辑的第二份实现。
SlotTestRuntime.create() 组装运行时,declare(children) 注册一个自动 frame,其逐 key 的 <div data-slot> 包裹层成为快照根,mount(plugin) 在真实 fiber 上运行功能,renderSlot(key, owner, opts?) 返回带限定查询与原位更新的 slot 局部视图:
const runtime = await SlotTestRuntime.create()
await runtime.declare({ 'feature-slot': {} })
const handle = await runtime.mount(FeaturePlugin)
const view = runtime.renderSlot('feature-slot', { owner: props })
expect(view.container).toMatchSnapshot()
await runtime.dispose()
mount 会预检必需服务,缺失时自明报错——先用 provide(name, value) 提供额外服务。运行时会提供不可用的 fileUpload 替身,使装配可以挂载;测试上传行为时,需要在挂载前替换 runtime.fileUpload.upload。storeOf(key, scopeKey) 返回渲染器交给 slot 组件的实时存储实例,用于身份与动作驱动写入断言。
可选渲染参数通过 entryKey 选择 keyed 条目,或通过 only 选择 list 条目;view.update(owner) 保留该选择。runtime.panelInfo 提供默认的 usePanelInfo 数据源,初始不选中全局面板。挂载生产 Layout 所有者之前,先调用 releasePanelInfoSource() 释放该数据源。dispose() 同时释放默认的工作区与面板信息根数据源;提前释放是幂等的,不会移除替代它们的所有者。
注册的快照序列化器把 CSS-module 哈希类名折回语义名(_frame_a1b2c3 → frame),使 .snap 文件只含结构,并把 <svg> 内部折叠为 data-content 指纹。需要自定义页面 frame 的套件改用 root.declare(children, Frame) 而非自动 frame;dispose() 沿单一轴拆除视图、功能 fiber、已铸 scope 与持久化存储状态,且幂等。
TestRemote 是 ctx.remote 面的替身:它把自己连同每个被脚本化的命名空间各注册一个服务,使注入 remote.<name> 的插件得以解除挂起;$on 订阅由显式的测试事件驱动器推动;$host 是普通可变字段,套件直接赋值即可脚本化带 home 或非 loopback 的 Host。UI 套件也在本包取用 RemoteError 构造器这个值——dsh-api-remotes facade 承载不了它,因为从套件发起的值 import 会拉起该装配尚未构建的 /remote 产物链。
按 Host 会答的码来脚本化失败,并以生产代码同样的方式断言——判 code,绝不判类:
import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime'
remote.goals.create.mockResolvedValue({
ok: false,
error: new RemoteError('goal/not-found', 'goal "g1" does not exist', { goalId: 'g1' }),
})
expect(view.getByRole('alert')).toHaveTextContent('goal/not-found')
上面的 slot 档把一个功能挂在替身上。整体档起真实装配:TestClient.start(plan, mock, options) 把 { rpc: mock.rpc } 装到 globalThis.__DSH_TRANSPORT__,进程内 import 每个 roster 行的 /client 模块(或取计划里的 provide 替换),用 graphFromRoster 合成启动图并把已加载模块交给生产模块系统,经生产 bootClient 启动,按需挂载 uiRenderer,再等 ctx.connection.state === 'connected'。它藏在深 import 后面,slot 档测试永不加载它:
// @vitest-environment jsdom
import { createClientTest, webApp } from '@deepseek-ai/dsh-client-test-runtime/src/assembly/index.ts'
import { ok } from '@deepseek-ai/dsh-remote-mock'
const test = createClientTest({ roster: webApp }, { mount: true })
test('registers into the sidebar', async ({ remote, start }) => {
remote.settings.describe.mockResolvedValue(ok({ writable: true, hasDocument: false, namespaces: [] }))
const client = await start()
expect(client.ctx.slots.entries('sidebar.settings')).toHaveLength(1)
})
createClientTest 使用原生 Vitest fixture:每个测试获得已加载 remoteDefaultResponses 的新 mock、等同于 mock.remote 的 remote Proxy,以及配置应答后才起机的 start()。重复启动共用一个 Promise,调用方必须 await 它来观察启动错误。fixture 收尾等待启动,即使断言失败也销毁客户端、检查漏配,并拒绝测试结束后保存的 start 调用。需要分别拥有多个客户端时直接用 TestClient.start。这些 fixture 隔离自己的状态,不隔离 location 等页面全局。
两档测试的所有命名空间都使用通用 Remote Proxy。装配测试使用 remote fixture;局部 TestRemote 可以接收 { settings: mock.remote.settings }。直接配置返回数据,并读取原生 .mock.calls。mutation 应答不会自动更新后续 describe 应答:场景发布新数据时,显式修改 remote.settings.describe.mockResolvedValue(...)。Proxy 文档拥有无构建类型说明和必需的构建后本地类型检查规则。
webApp 是 web profile 的浏览器 roster,首次 import 装配入口时从它的 bundle(先 dsh-base、再 dsh-web-app)按启动器的方式现读,只是匹配不到任何行的补丁在这里抛错、启动器只警告:每个 bundle 的 dsh.bundle.patch 列表用 include 插件的 YAML 方言解析、用它的 applyEntryPatches 合成,每个未禁用且其包声明 dsh.client.platform === 'web' 的行成为一行,带上该声明的 inject 与 immediately;bundleRoster(bundles) 对任意 bundle 列表做同样的事。没有任何东西从 bundle 拷贝出来,bundle 一改下次跑测试就能看见。webApp.closure(names) 保留点名的行及其传递注入的全部行(即按 bundle 组合方式起这些插件所需的行),webApp.pick(names) 与 webApp.without(names) 手工裁剪,三者都对未知名字抛错,ClientRoster.of(rows) 内联构造一份。remoteDefaultResponses 是 roster 在没有 session、没有 workspace、默认设置下启动时恰好会打的那些 Remote 端点的默认响应;测试用 mock.load(table) 在其上叠加自己的 RemoteTable,任何没有规则的调用都会在 dispose() 时经 mock.assertNoUnmatched() 让测试失败。mount 要求 roster 提供 uiRenderer;否则 start 响亮失败而不是返回一个空容器。client.connection 是 roster 的 Connection 服务(没有任何 Context 增强声明它),connectTimeoutMs 限定等就绪的时长,超时消息列出 mock log。reload(name) 按 client-hmr 的方式重建一个 Loader entry(先拆 registry,再 entry.refresh()),并在 worker 的启动轮次内装上本客户端的载体,重建的 connection 行因此读到自己的 mock;unload(name) 移除它;flush() 在 act 内让 React 落定。jsdom 既没有 EventSource(client-hmr 在 apply 时打开一个)也没有 ResizeObserver(布局组件挂载时观察尺寸),所以 start 对缺失的全局装惰性桩、dispose 只移除它装的那些——这是 jsdom 的缺口,不是产品需求。每个 roster 里的 @deepseek-ai/dsh-api-remotes 行都会被去掉:它生成的 Remote 客户端只存在于构建后的 lib/,而 remote.<ns> 正是本档要替掉的东西。start 改为给 roster 注入的每个 remote.<ns> 服务(加上此刻 mock 登记过规则的命名空间;之后才首次登记的命名空间没有代理)提供一个无契约代理;ctx.remote.<ns>.<method>(...args) 变成对端点 <ns>/<method> 的调用,携带位置参数,mock 登记了 stream() 脚本的走流、否则走一元,并沿用生成客户端的结果折叠(载体抛错折成 gateway/internal,中止折成 gateway/cancelled)。没有规则的端点照样发出,所以 mock 会记下它、dispose() 让测试失败。
当功能套件要在真实运行时下检验 slot、存储、渲染与销毁时使用本测试台——生产 SlotRegistry、渲染器与 provide bundle 物化都会被挂载,绝不重实现。它是客户端测试基础设施:永远不触及模型请求,功能包仅以 devDependencies 依赖之。
mount 自明报错并列出缺失名称;请先用 provide() 提供。declare 之前尝试渲染——renderSlot 自明报错;请先声明该 key。当包级约定不够用时阅读以下页面。它们从测试台逐步进入它所挂载的生产机制以及使用它的测试。
SlotRegistry 约定。无;本包是浏览器侧测试基础设施,不会发起任何模型请求。
无;本包既不组装也不发送提供方请求。
这些限制说明本测试台如何被消费。它们是当前包约束,不是任务积压。
remote.<ns> 代理转发位置参数,不经过生成的 zod 校验、wire 名映射或 scoped 身份注入;mock handler 直接接收这些参数,生成客户端仍由 built-artifact e2e 车道覆盖。invoke 与 invokeStream——不做 $mount 生命周期检查,流失败不经 normalizeConnectionStream 重新标记,一元拒绝由代理自己用 Gateway 客户端导出的 carrierFailure 与 cancelledFailure 折叠。ctx.remote.$stream、$on、$host 是真 Gateway 客户端的。RemoteTable.streams、mock.stream(endpoint))的流端点记为 unary 漏配,产品代码收到的是折叠结果而不是失败的流。remoteDefaultResponses 声明了 roster 启动后才打开的流;无论哪种,dispose() 都会让测试失败。node 环境类型,好让 roster 读取器使用 node:fs;slot 档的源码也在这些类型下编译。sessionSnapshot 只包含 Session 控制器状态,conversationSnapshot 包含与目标无关的 Conversation 状态,chatSnapshot 包含 Chat 目标状态。组装测试提供 Session 事件条目,而不是向 SessionSnapshot 添加 Conversation 或 Chat 字段。