测试
怎样测试各种插件。插件文档回放录制的响应,解析器和纯逻辑用 bun test 写单元测试,用按脚本应答的模拟模型完整跑通助手的流程,远程插件用 demo-canteen 示例来试。
大多数校园插件分三层:一次 HTTP 请求、一个读取响应的解析器,以及一个整理结果的工具(tool)。请求依赖真实的校园服务器,所以测试时用录制好的响应代替它。解析和整理都是普通代码,可以在你的电脑上测试。之后,再在 App 里把整个插件跑一遍。
本页先介绍插件文档,然后依次介绍 TypeScript 插件、模拟模型、演示用的远程插件和真机测试。除非另有说明,所有命令都在插件仓库里运行。
插件文档(PLUGIN.md)
PLUGIN.md 插件用插件仓库里的作者命令行工具(CLI)测试。它会回放插件 tests/*.yaml 里录制的响应:
bun run plugin validate plugins/classrooms # App 会做的检查
bun run plugin test plugins/classrooms # 回放 tests/*.yaml
bun run plugin run plugins/classrooms overview '{}' # 真实请求,仅限公开域名validate 执行的就是 App 安装插件时做的那套检查。test 回放 tests/*.yaml。run 会发出真实请求,而且只能访问公开域名。
bun test 包含 plugins/documents.test.ts。它加载每个 plugins/<id>/PLUGIN.md,回放其中所有录制好的测试。持续集成(CI)会在每次推送到 main 和每个 pull request 上运行 bun test,所以只要某个插件文档或它录制的响应(fixture)出了问题,CI 就会失败。测试文件的格式见快速上手。本页其余部分讲的是 TypeScript 插件。
单元测试(bun test)
在插件仓库里运行:
bun test # 工作区里所有的 *.test.ts
bun test plugins/focus # 只测一个插件
bunx tsc --noEmit -p . # 对 SDK 和插件做类型检查focus 是内置插件一页里的示例插件,请换成你自己插件的目录。
下面几个习惯能让测试真正有用:
- 测试和代码放在一起:
src/parse.test.ts紧挨着src/parse.ts。 - 解析器。 把真实响应存成 fixture(
src/fixtures/*.html|json),替换掉其中的个人信息,然后断言解析结果。校园网页时常改版,改版之后,fixture 能准确地告诉你哪里出了问题。 - 时间。 把
now作为参数传进去,不要在逻辑里直接读取Date.now(),这样测试就能固定时间。SDK 的重复规则、课表和格式化函数都接受now参数。 - React Native。 导入了
react-native的模块,不做 mock 的话 bun 无法加载。要么把逻辑放在普通模块里,要么像plugins/i18n.test.ts那样 mock 掉react-native:
import { mock } from 'bun:test';
mock.module('react-native', () => ({
StyleSheet: { create: (s: unknown) => s },
Platform: { OS: 'android', select: (o: Record<string, unknown>) => o.android ?? o.default },
View: () => null,
}));用假的上下文测试工具
工具的 execute 函数接收输入和一个上下文对象 ctx。测试时,你可以自己构造一个简单的假上下文。下面的假上下文带有一个内存里的 KV 存储,测试可以先开始一次专注,再把它读出来:
import { expect, mock, test } from 'bun:test';
import type { KV, ToolContext } from '@pku-harness/sdk';
// index.ts 引入了小组件,小组件又引入了 react-native:加载插件之前先 mock 它。
mock.module('react-native', () => ({ View: () => null }));
const { focusPlugin } = await import('./index');
function memoryKV(): KV {
const m = new Map<string, unknown>();
return {
get: async <T,>(k: string) => m.get(k) as T | undefined,
set: async (k, v) => void m.set(k, v),
delete: async (k) => void m.delete(k),
};
}
test('start, then today shows the running session', async () => {
const ctx = { pluginId: 'focus', actor: 'user', kv: memoryKV(), services: {} } as unknown as ToolContext;
await focusPlugin.tools.start.execute({ label: '高数' }, ctx);
const today = (await focusPlugin.tools.today.execute({}, ctx)) as { running: { label: string } | null };
expect(today.running?.label).toBe('高数');
});由于 index.ts 引入了小组件,小组件又引入了 react-native,所以测试要先 mock 它,再加载插件。
execute 本身不校验输入,App 会在调用它之前完成校验。如果校验逻辑很重要,请用 tool.input.safeParse(...) 单独测试。
模拟模型
助手按循环工作:把对话发给模型,运行模型要求调用的工具,再把结果发回给模型。想在没有 API key、也没有真实模型的情况下测试这个循环,可以用 examples/mock-llm。它是一个小服务器,接口和 OpenAI 的 Chat Completions API 相同,按固定的脚本应答:
bun examples/mock-llm/server.ts # 地址 http://localhost:8788/v1,模型名 "mock-agent"在 App 里打开「设置 → AI 服务 → OpenAI 兼容」。Base URL 填 http://10.0.2.2:8788/v1,模拟器通过这个地址访问你的电脑;模型填 mock-agent。模拟模型会根据你消息里的关键词挑选一个工具来调用,再总结工具的结果:
| 消息里包含 | 调用 |
|---|---|
提醒 / remind | scheduler__create(每 3 天 09:00,共 16 次) |
马上 / soon | scheduler__create(一次性,2 分钟后) |
看板 / dash | dashboard__upsertWidget(一个绑定 timetable.today 的小组件) |
表单 / form | ui__render(一个充值表单) |
remember | memory__remember |
食堂 / canteen | mcp-demo-canteen__reserveSeat(需要先装好演示插件) |
who | 原样回显注入的 <context> 块 |
想让它调用你自己的工具,就在本地扩展它的 plan() 函数,写上这些工具的模型名。
演示用远程插件
bun examples/demo-canteen/server.ts # 地址 :8787/mcp,bearer token 为 demo-tokenexamples/demo-canteen 是一个使用模拟数据的远程插件,在 8787 端口的 /mcp 上提供服务,bearer token 是 demo-token。在「设置 → 插件 → 添加远程插件」里填入 http://10.0.2.2:8787/mcp 和 token demo-token。它有一个读取工具(menu)和一个修改工具(reserveSeat),你可以借此看到远程修改类工具弹出的确认提示。
在真机上
- 开发版能识别几个特殊的深链接(deep link),可以直接运行一个工具或发起一轮对话:
pkuharness://dev?tool=<plugin>.<tool>&input=<json>和pkuharness://dev?chat=<text>。加上&actor=agent就以助手的身份运行工具,从而看到「先询问」的确认提示。 - 页面和小组件要在深色、浅色两种主题下,以及中英两种语言下各检查一遍。
- 真实的校园数据需要真实账号。读取类工具先在自己的账号上测试,再请别人帮忙;含有个人信息的响应绝不要提交。