PKU harness
Build a plugin

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 only

validate 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 + plugins

The 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.ts beside src/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 now in as an argument instead of reading Date.now() inside your logic. A test can then fix the time. The SDK's recurrence, timetable and format helpers all take now.
  • React Native. Modules that import react-native won't load under bun unless you mock it. Keep logic in plain modules, or mock react-native the way plugins/i18n.test.ts does:
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:

plugins/focus/src/index.test.ts
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 containsIt calls
提醒 / remindscheduler__create (every 3 days at 09:00, 16 times)
马上 / soonscheduler__create (once, in 2 minutes)
看板 / dashdashboard__upsertWidget (a widget bound to timetable.today)
表单 / formui__render (a top-up form)
remembermemory__remember
食堂 / canteenmcp-demo-canteen__reserveSeat (needs the demo plugin installed)
whoEchoes 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-token

examples/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> and pkuharness://dev?chat=<text>. Add &actor=agent to 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.

On this page