测试
路由模块通过框架来测试,而不是通过手写的打包器配置。承载该 harness 的有两个子路径,且两者都是选择
加入的:@rstest/core 与 react 是可选 peer 依赖,因此从不测试路由的项目两者都不会安装。渲染还需要
@agent-bundle/runtime,只要项目拥有路由模块就已经拥有它,生成的入口以同样的方式导入它。
配置辅助函数
agent-bundle/rstest 支持 @rstest/core 版本 ^0.11.10 || ^0.12.0。
运行浏览器测试时,请让 @rstest/browser 和 @rstest/core 使用相同的版本;
MCP App 示例使用 0.12.2,并用 playwright 提供浏览器。
agent-bundle/rstest 会编译项目一次,与构建执行的路由图编译完全相同,但不构建产物,并返回一个普通的
Rstest 配置对象,其中携带测试清单、路由加载器、React 的 react-server 解析条件以及自动 JSX 运行时:
route-unit 测试默认匹配 tests/route-unit/**/*.test.{ts,tsx},并且需要自己独立的 Rstest 运行,
因为渲染一个路由要求整个 worker 进程启用 Node 的 react-server 条件。请把它们排除在项目普通的
rstest 运行之外。
渲染一个路由
agent-bundle/test 提供这些辅助函数。renderRoute 执行一个路由,通过编译后的 route id,或直接导入
模块,它经过真实的渲染器与真实的请求存储,并解析为最终的 Agent Document:
renderRoute 接受 input、args(CLI 路由)、请求 context 覆盖(包括一个 context.progress
上报器)、渲染 limits 以及一个 signal。它返回文档、路由上报的请求作用域进度、解析出的 provenance,
以及由路由自己的 resultSchema 解析后的取值。无论调用方是否提供自己的上报器,进度都会被记录。
带类型的 route id、输入与结果
编译器生成的 .agent-bundle/routes.d.ts 会把项目的路由契约注册到 @agent-bundle/runtime 的
Register 接口上,与它已经声明 provider 键的方式相同。一旦该文件成为项目 TypeScript 程序的一部分
(create-agent-bundle 模板默认就把 ".agent-bundle/routes.d.ts" 加入 tsconfig.json 的 include,该文件本身保持 gitignored。
现有项目也必须在每个使用这些类型的 tsconfig 中包含此文件,并运行自己的 TypeScript 检查。
框架验证只生成声明,不分析项目引用,也不再发出 AB4834。包含声明后,字符串字面量路由 id、
输入和结果都会根据该路由的 schema 检查,编辑器也会补全有效 id。
input 是调用方发送的内容,schema 的输入类型:带 .default() 的字段是可选的,经
.transform() 的字段按 wire 上的写法拼写,而路由组件收到的是 schema 的输出,与生成的服务器交给它的
完全一致。renderRoute 在同一边界用路由自己的 inputSchema 解析输入:组件看到的是已填默认值、已应用
transform 的值;schema 拒绝的输入会在任何 provider 或组件运行之前,以 invalid-input 测试错误失败。
result 是 resultSchema 的输出。两侧都会被注册:RegisteredRouteInput<Id>(来自
@agent-bundle/runtime)是调用方一侧,RegisteredRouteParsedInput<Id> 是组件一侧,也就是
loadRouteModule(id).inputSchema.parse(...) 的返回类型。
渲染事件路由注册的是测试工具实际接受与返回的内容:input 是 { canonical, native } 载荷(signal 由测试
工具自行提供),而 result 为 undefined,因为事件模块不导出 resultSchema。请用
createEventRouteInput('tool/after', envelope, { host: 'claude' }) 从宿主信封构造这个输入,而不要手写:它按
宿主与事件校验信封,并通过与制品包装器相同的表投影出 canonical.payload;标注为
AgentEventRouteProps<'tool/after'> 的路由会把注册的 input 收窄到该事件族,因此把 stop 信封的输入传给它是
类型错误。
这一切都不是必需的。目标若类型为 string 而非字面量,仍然合法,可用于动态查找;直接导入模块作为目标
不受影响;而没有把生成文件纳入程序的项目,或尚未运行过一次构建或 agent-bundle dev 的项目,看到的
仍是先前的类型:任意字符串 id、unknown 输入、unknown 结果。@agent-bundle/runtime 的
RegisteredRouteId、RegisteredRouteInput 与 RegisteredRouteResult 直接命名这一注册表面,便于
你自己封装。
同一份注册会流向测试工具中每一个接受 route id 或路由载荷的表面,正如 TanStack Router 的那一个
Register 会流向 Link to、useNavigate 与 RoutesByPath。invokeMcpTool('find', { input }) 与
getMcpPrompt('brief', { input }) 会把线上名称对照已注册的工具或提示名称做检查(tool:curator/find
对应 find),并根据该路由的 inputSchema 为 input 定型,省略 server 且两个服务器注册了同名路由时为二者的
联合类型,传入字面量 server 时则只取该服务器上的那条路由(server 本身也会对照已编译的服务器名称
做检查);runContractMatrix、runPackedContractMatrix、runDevEpochContractMatrix 与
runInstalledHostContractMatrix 会根据对应路由为 fixtures 中每个已注册键定型(input、inputs、
cancellation.input 与生命周期 transition),而 MCP App 路由的键或动态构造的
Record<string, ContractRouteFixture> 仍然合法;invokeCli 把已执行命令的 routeId 报告为已注册的
cli: 或 tool: id(argv 仍为 readonly string[]);agent-bundle/eval 的
expectMcpCall({ server, tool }) 会把字面量 tool 对照本项目字面量 server 上已注册的工具做检查,
第三方服务器则不受约束。RegisteredMcpServerName、RegisteredMcpRouteName 与 RegisteredMcpRouteId
命名一个已注册 id 所编码的服务器与协议名称。以下表面有意保持不定型:readMcpResource(线上 URI,注册
表中并不携带)、runScript(脚本路由不注册契约)、structuredContent(线上仅在文档取值为对象时携带)
以及 agent-bundle/api(它面向任意项目 root)。
testManifest() 暴露编译后的路由清单,因此一个测试套件可以在进程内遍历每个路由,而不必为每个路由付出
一次构建的代价。任何失败,未知路由、被拒绝的路由种类、被拒的输入、渲染错误,都会指明 route id、target
种类与模块 provenance。
loadRouteModule(id) 通过 renderRoute 所用的同一个惰性加载器,返回某个 id 背后已求值的模块。它就是模块
对象本身,因此 inputSchema 与 resultSchema 按引用就是路由自己的 schema 实例,config 是它的静态配置
导出,default 是它的组件(普通 .ts 脚本导出的是 main)。于是 schema 一致性套件不再需要手工维护一份
静态路由导入列表:遍历 testManifest().routes 并逐个加载即可。
字面量 id 会像 renderRoute 检查其目标那样对照已注册的 id 进行检查,schema 的解析结果也从注册中获得类型,
因此被移除的放置会成为类型错误。约定式脚本可以加载,但不属于注册的一部分,因此 script: 字面量会不经检查地
被接受。加载会像 import 一样对模块求值,自执行的普通脚本会运行。在不是由 agentBundleRstest() 构建的
测试池之外,调用会以 manifest-unavailable 关闭式失败;描述另一个项目的 manifest 会给出与 renderRoute
相同的不匹配报告并拒绝,因为路由加载器绑定到产生它们的那次编译。
跨多次渲染共用一份 state
每次 renderRoute 都会为这一次渲染重新挂载项目声明的 state,以及它的 notice ledger,并在渲染结束后关闭,
因此两次渲染永远看不到彼此的写入。对于隔离的路由测试这是正确的默认值,但一个"在某个事件上记录、在下一个
事件上读取"的旅程需要每一步都落在同一份已挂载的 state 上。mountTestState() 把 manifest 的 state 定义
只挂载一次,遵循同样的驱动规则(workspace-durable 定义打开一个 close() 时会移除的一次性 sqlite 根目录;
其他生命周期使用内存驱动),并把同一个 state 与 noticeLedger 交给任意多次展开了它的 context() 的
renderRoute 或 renderRouteEvents 调用:
read() 与 notices() 是所有者自身的快照,而不是每次渲染的副本;state 与 noticeLedger 也会暴露出来,
以便直接 dispatch。每次渲染都会把共享的所有者绑定到它自己的请求信号上,与生成的请求作用域完全一致,
因此某次渲染的 signal 仍然会取消该次渲染的 state 操作。withTestState(async (state) => { ... }) 是同一次挂载限定在一个回调内的形式,回调
结束时关闭。快照类型来自类型参数,state 注册不携带 state 类型,或者(经过检查地)来自
options.definition,它会挂载该定义而不是 manifest 的定义(external 定义需要 options.driver,
因为它没有指名任何测试工具能打开的存储)。省略了已挂载句柄的渲染仍会获得自己隔离的所有者,因此两种写法
可以在同一个套件中混用。
针对 Agent Document 契约的匹配器:toHaveStatus、toContainMarkdown、toContainText、
toHaveValue、toHaveError 与 toHaveNodeKinds。
这就是 route-unit 证明级别,且仅此而已:它证明一个路由模块渲染出了它所声称的文档。它不是关于 MCP 传输、打包产物或浏览器表面的证据。
证明级别
这些级别是刻意分开的。每个辅助函数都会把自己承载的级别写进 provenance,并在每次失败时打印它,因为 某一级别的通过绝不是另一级别的凭据。
inspectWorkbenchSurface() 返回
{ application, routes, lifecycles, counts, advanced }。application 是 Workbench 所渲染的同一棵树
形状,因此测试断言的是叶子与路径,而不是一份固定的页面列表。
WorkbenchPageName 与 workbenchPageLabel 不再导出。
对实时 Workbench 做浏览器验收时,应运行一条真实路由并断言已填充的 Trace,而不只是检查空状态。
data-testid="trace-timeline" 标识时间线,trace-entry 标识每个可选择行,trace-group 标识关联组,
trace-detail 标识所选条目的详情视图。断言行的来源、kind 与关联证据,然后使用 Open route,
并验证路由工作区已加载记录下来的 ?invocation=<id> 快照。
在这九个级别之外还有两个并列级别,共十一个。agent-bundle/test/browser 为浏览器安全的 browser-app 级别
提供 mountBrowserApp,用于在真实浏览器页面中把生产编译的 MCP App HTML 挂载到产品桥接层之上;
而 simulated 复用不带 sessionEvidence 的已安装宿主辅助函数 openInstalledHostMcpServer,
一份输出的捆绑包被直接投放到隔离的宿主形状根目录并在没有宿主自有安装的情况下启动,它比
host-install 更弱。
mountBrowserApp 会根据 toolName 与 toolDefinition 派生开场调用的
hostContext.toolInfo,发布 toolInput,并把该工具信息注入调用方的 host.context;
未提供该上下文时则注入默认的 inline desktop 上下文。不要在 host.context 中重复提供
toolInfo;冲突的开场工具名会被拒绝。传入 toolResult
会在挂载期间发布已经结算的结果。若开场调用仍在进行中,则省略它,之后再用
publishToolResult(...)(包括 isError: true 的结果)或 publishToolCancelled(reason?)
结算且只能结算一次。每个发布方法都会返回桥接层是否接受了该终态通知。如果 App 再次初始化,
harness 会重放开场输入以及已接受的终态通知。
expectEvents 针对渲染事件流做断言。toContainSequence 对序列是宽容的,多出一个 progress 或
replace 帧是合法的,不会把一次本该通过的渲染判红,而缺帧、乱序或序号回退仍然会失败。
toHaveMonotonicSequence、toCompleteOnce、toHaveProgress 与 toHaveNoErrors 覆盖契约的其余部分。
进程证据刻意很昂贵
在打包级别中,只有 packed-stdio 及其严格更强的升级版 packed-deleted-source 属于进程证据:打包一次、
安装一次、构建一次、移除并核实源码缺失一次、启动一次,然后在这一个会话内遍历每个路由的断言。dev-epoch
是另一种形态的进程证据,Workbench 自己的、锁定到 epoch 的生成式 stdio 进程,而非打包后的产物,因此
dev-epoch 通过并不能说明打包会交付什么。deleted-source 的
旅程还会从生成式服务器读取内嵌的 MCP App 资源;它不证明原生宿主的安装或派发,也不证明那种把产物复制
到别处的安装方式。
host-install 是独立的、关于已安装布局的进程证据。它的确定性适配器模拟通道是无条件运行的,可用的
Claude 与 Codex 二进制文件还会证明它们各自公开的安装路径,而 Cursor 会显式记录它那不可用的、非交互式
宿主会话表面。
契约矩阵
契约矩阵是框架自有的生成式插件线上契约测试套件。三个入口共享同一份实现;边界差异是显式的能力标记,而
不是分叉的检查逻辑。项目只需提供 fixture,合法输入、为每个内存内工具路由声明的 resultCompat 策略、
可选的 previousResults 负载、可选的 cancellation case,以及一个可选的、带声明式预期的确定性生命
周期状态转换驱动。
runContractMatrix(mcp-in-memory) 通过 SDK 的内存内传输,对真实的生成式服务器打开一个真实的
MCP 客户端并运行完整矩阵。它证明:线上表面相对编译器清单的完整性、fixture 覆盖率、成功路径的调用扫描、
经由每个工具路由自己的 resultSchema 的 JSON 序列化往返、在序列化负载上声明的 additive 或 closed
兼容行为、当前 schema 对旧版服务器负载的接受度、对由所公布的 listTools 输入 JSON Schema 派生出的
负面输入的拒绝,以及调用中途取消的卫生性。内存内传输可能在不做序列化的情况下传递结构化取值,因此矩阵
会在校验之前用一次显式的 JSON.parse(JSON.stringify(...)) 往返来填上这个缺口。MCP App 在表面注册上
被报告为 not-applicable,因为内存内级别并不注册它们。
runPackedContractMatrix(packed-stdio / packed-deleted-source) 针对一个已经打开的打包会话
运行,那一次打包旅程自己拥有会话的打开与关闭。它为以下内容提供进程 stdio 证据:表面完整性(包括
listResources 中编译后的 MCP App 资源 URI)、fixture 覆盖率、成功路径扫描、对所公布输入 schema 的
拒绝,以及客户端侧的取消卫生性。它无法加载项目路由模块,因为源码可能已被删除并核实缺失,所以序列化
往返、兼容性探测与版本偏移检查,包括它们逐生命周期阶段的变体,都会以诚实的理由被报告为
not-applicable。打包后的服务器在返回之前会用自己内置的 resultSchema 校验每个工具结果;一次成功的
扫描调用就是那份证据。
runInstalledHostContractMatrix(host-install) 针对 openInstalledHostMcpServer 返回的、已经
打开的会话运行。打开器会从已安装的根目录读取宿主输出的 MCP 文档,核实清单、组件路径、资源路径与钩子
路径以及产物文件摘要,启动那条已安装的命令,并从真实的 MCP initialize 结果中观察运行中的版本。它的
报告分别记录源码、已构建产物、已安装产物与运行中进程的版本,任何取值缺失或不一致都会失败关闭。元数据
记录被观察到的宿主二进制版本、适配器修订号、清单与 schema 摘要,以及框架版本。以模块为后端的检查仍然
诚实地是 not-applicable,因为加载项目模块会跨回源码与构建树。
资源路由的 fixture,以及 apps: 'explicit' 下 MCP App 路由的 fixture,必须设置
kind: 'resource'。写成空的 {} 会让该路由的 coverage 检查以
resource/app route fixture must be { kind: "resource" } 失败;仅有键名并不算覆盖该路由。反过来,
工具、prompt 与命令行路由拒绝这个 kind,它们的 fixture 携带的是 input。
打包与已安装宿主这两个入口接受同样的 fixture 形状,外加它们所针对的会话:runPackedContractMatrix
需要那个已打开的打包会话,以及在移除源码之前编译出的清单;而 runInstalledHostContractMatrix 需要
openInstalledHostMcpServer 返回的会话以及同一份清单。
生命周期 fixture
生命周期 fixture 会在矩阵那一个已打开的客户端上重放
unknown → queued → running → first-progress → repeated-progress → terminal。框架会校验每个阶段的
结构化内容与渲染输出、additive 与 closed 兼容性、结算之前的实时进度、日志累积、声明的通知、幂等的
提交重放,以及带类型的预算拒绝。
调用方提供的同存储 restart 回调会在该边界上补充持久性证据;没有它时,这项检查诚实地是
not-applicable。打包侧的调用方应把该回调接到既有打包旅程的重启上,而不是另建一条打包、构建与安装的
路径。生命周期 fixture 可选的 state.catalog 断言会把它声明的 id 与 lifetime 固定到同一次挂载状态
重放所使用的编译器清单上。
事件路由与运行时身份
当编译后的清单包含事件路由时,打包与已安装宿主这两个边界会在顺序矩阵事件之前以及整个过程中,采样只读的
事件运行时状态。如果预热的 instanceId 发生变化、产物 epoch 漂移,或者可用性降级为
runtime-restarted 或 runtime-unavailable,runtime-instance-identity 检查就会失败。内存内运行
以及不含事件路由的编译产物,会诚实地把运行时身份报告为 not-applicable。
没有任何矩阵边界证明浏览器 App HTML 或产物重建重放。
当所公布的输入 schema 声明了 additionalProperties: false 时,普通的 z.object 工具路由仍可能在不
触发协议失败的情况下剥掉未知键。当其他生成的负面输入仍然证明了拒绝路径时,负面输入检查会记录这种容忍。
失败的矩阵会抛出一个聚合后的 AgentTestError,错误码为 contract-violation,其中指明每个失败的路由、
每项失败的检查,以及这次运行实际承载的证明级别标签。