Testing
How to test each kind of plugin. Replay recorded responses for plugin documents, unit-test parsers and pure logic with bun test, run the assistant end to end with the scripted mock model, and try remote plugins with the demo-canteen example.
Most campus plugins have three layers: an HTTP request, a parser that reads the response, and a tool that shapes the result. The request depends on a live campus server, so tests replace it with a recorded response. The parser and the shaping are ordinary code that you can test on your computer. After that, try the whole plugin in the app.
This page starts with plugin documents, then covers TypeScript plugins, the mock model, the demo remote plugin and testing on a device. All commands run in the plugins repository unless the text says otherwise.
Plugin documents (PLUGIN.md)
You test a PLUGIN.md plugin with the author command-line tool (CLI) in the plugins repository.
It replays the recorded responses in the plugin's tests/*.yaml:
bun run plugin validate plugins/classrooms # the checks the app runs
bun run plugin test plugins/classrooms # replay tests/*.yaml
bun run plugin run plugins/classrooms overview '{}' # live, public hosts onlyvalidate runs the same checks the app runs at install time. test replays tests/*.yaml. run
sends real requests, and only to public hosts.
bun test includes plugins/documents.test.ts. It loads every plugins/<id>/PLUGIN.md and replays
all of its recorded tests. Continuous integration (CI) runs bun test on every push to main and
every pull request, so it fails as soon as a document or its recorded responses (fixtures) break.
The test format is described in the quickstart. The rest
of this page covers TypeScript plugins.
Unit tests (bun test)
Run these in the plugins repository:
bun test # every *.test.ts in the workspace
bun test plugins/focus # one plugin
bunx tsc --noEmit -p . # typecheck SDK + pluginsThe commands run every test in the workspace, the tests of one plugin, and a type check of the SDK
and all plugins. focus is the example plugin from Bundled plugin;
use your own plugin's folder instead.
A few habits make these tests useful:
- Put tests next to the code:
src/parse.test.tsbesidesrc/parse.ts. - Parsers. Save real responses as fixtures (
src/fixtures/*.html|json), replace any personal data in them, and assert on the parsed result. Campus pages change from time to time, and when they do, a fixture shows exactly what broke. - Time. Pass
nowin as an argument instead of readingDate.now()inside your logic. A test can then fix the time. The SDK's recurrence, timetable and format helpers all takenow. - React Native. Modules that import
react-nativewon't load under bun unless you mock it. Keep logic in plain modules, or mockreact-nativethe wayplugins/i18n.test.tsdoes:
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,
}));Testing a tool with a fake context
A tool's execute function takes its input and a context object, ctx. In a test you can build a
small fake context yourself. Here the fake context has an in-memory KV store, so the test can start
a session and then read it back:
import { expect, mock, test } from 'bun:test';
import type { KV, ToolContext } from '@pku-harness/sdk';
// index.ts imports the widget, which imports react-native: mock it before loading the plugin.
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 imports the widget, which imports react-native, so the test mocks it before loading
the plugin.
execute doesn't validate its input; the app does that before calling it. If validation matters,
test it separately with tool.input.safeParse(...).
Mock model
The assistant works in a loop: it sends the conversation to a model, runs the tools the model asks
for, and sends the results back. To test that loop without an API key or a real model, use
examples/mock-llm. It is a small server with the same interface as OpenAI's Chat Completions API,
and it answers from a fixed script:
bun examples/mock-llm/server.ts # http://localhost:8788/v1, model "mock-agent"In the app, go to Settings → AI service → OpenAI-compatible. Set the base URL to
http://10.0.2.2:8788/v1, which is how the emulator reaches your computer, and set the model to
mock-agent. The mock model picks a tool based on keywords in your message, then summarises the
tool's result:
| Message contains | It calls |
|---|---|
提醒 / remind | scheduler__create (every 3 days at 09:00, 16 times) |
马上 / soon | scheduler__create (once, in 2 minutes) |
看板 / dash | dashboard__upsertWidget (a widget bound to timetable.today) |
表单 / form | ui__render (a top-up form) |
remember | memory__remember |
食堂 / canteen | mcp-demo-canteen__reserveSeat (needs the demo plugin installed) |
who | Echoes the injected <context> block |
To make it call your own tools, extend its plan() function locally with their model names.
Demo remote plugin
bun examples/demo-canteen/server.ts # :8787/mcp, bearer token demo-tokenexamples/demo-canteen is a remote plugin with mock data. It listens on port 8787 at /mcp and
expects the bearer token demo-token. In Settings → Plugins → Add remote plugin, add
http://10.0.2.2:8787/mcp with the token demo-token. It has one read tool (menu) and one write
tool (reserveSeat), so you can see the approval prompt that remote write tools show.
On a device
- Development builds accept special deep links that run a tool or a chat turn directly:
pkuharness://dev?tool=<plugin>.<tool>&input=<json>andpkuharness://dev?chat=<text>. Add&actor=agentto run the tool as the assistant, which shows the ask-first prompts. - Check screens and widgets in the dark and the light theme, and in both languages.
- Real campus data needs a real account. Test read tools on your own account before you ask anyone else to, and never commit responses that contain personal data.
Languages (i18n)
How a bundled plugin provides Chinese and English. Manifest strings get English versions in `i18n.en`, screen and widget text goes in a `defineStrings` table, dates go through the SDK formatters, and text for the model stays untranslated.
Porting from pkucli
pkucli is the protocol reference for PKU services. How to port endpoints, login steps and quirks from its Rust crates into a plugin, leave sign-in to the app, and credit the source file.