PKU harness

测试

怎样测试各种插件。插件文档回放录制的响应,解析器和纯逻辑用 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 存储,测试可以先开始一次专注,再把它读出来:

plugins/focus/src/index.test.ts
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。模拟模型会根据你消息里的关键词挑选一个工具来调用,再总结工具的结果:

消息里包含调用
提醒 / remindscheduler__create(每 3 天 09:00,共 16 次)
马上 / soonscheduler__create(一次性,2 分钟后)
看板 / dashdashboard__upsertWidget(一个绑定 timetable.today 的小组件)
表单 / formui__render(一个充值表单)
remembermemory__remember
食堂 / canteenmcp-demo-canteen__reserveSeat(需要先装好演示插件)
who原样回显注入的 <context> 块

想让它调用你自己的工具,就在本地扩展它的 plan() 函数,写上这些工具的模型名。

演示用远程插件

bun examples/demo-canteen/server.ts   # 地址 :8787/mcp,bearer token 为 demo-token

examples/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 就以助手的身份运行工具,从而看到「先询问」的确认提示。
  • 页面和小组件要在深色、浅色两种主题下,以及中英两种语言下各检查一遍。
  • 真实的校园数据需要真实账号。读取类工具先在自己的账号上测试,再请别人帮忙;含有个人信息的响应绝不要提交。

本页目录