Files
SnowLuma/packages/mcp/tests/action-contracts.test.ts
TanTan fa9c73363d
Dev Build / cleanup (push) Waiting to run
Dev Build / typecheck (push) Waiting to run
Publish MCP to npm / Build, test, and publish MCP (push) Waiting to run
Publish SDK to npm / Build, test, and publish SDK (push) Waiting to run
Sync action catalog to docs / sync (push) Waiting to run
Publish UI to npm / Build and publish UI (push) Waiting to run
Build and Release / preflight (push) Waiting to run
SnowLuma v2.4.2 source snapshot
2026-11-13 09:02:00 +08:00

246 lines
9.8 KiB
TypeScript

import { describe, expect, it, vi } from 'vitest';
import { AjvJsonSchemaValidator } from '@modelcontextprotocol/sdk/validation/ajv-provider.js';
import { collectActionDocs } from '@snowluma/onebot/action-docs';
import { ApiHandler, type ApiActionContext } from '@snowluma/onebot/api-handler';
import type { JsonObject, JsonValue } from '@snowluma/onebot/types';
import { ACTIONS } from '../src/generated/catalog';
import { callTool } from '../src/tools';
const validator = new AjvJsonSchemaValidator();
function resultValidator(name: string) {
const action = ACTIONS.find((entry) => entry.name === name);
if (!action?.returnsSchema) {
throw new Error(`missing result schema: ${name}`);
}
return validator.getValidator(action.returnsSchema);
}
function matchesResult(name: string, data: unknown): void {
const result = resultValidator(name)(data);
expect(result.valid, `${name}: ${result.errorMessage}`).toBe(true);
}
/** Unexpected dependencies fail rather than contacting an account or a network. */
function context(values: Record<string, unknown> = {}): ApiActionContext {
return new Proxy(values, {
get(target, key) {
if (typeof key === 'string' && Object.hasOwn(target, key)) {
return target[key];
}
throw new Error(`unstubbed action context: ${String(key)}`);
},
}) as unknown as ApiActionContext;
}
describe('bundled action contracts', () => {
it('matches the owning specs, without relying on a second hand-authored registry', () => {
expect(ACTIONS).toEqual(JSON.parse(JSON.stringify(collectActionDocs())));
});
it.each(ACTIONS)('$name publishes compilable schemas and schema-valid examples', (action) => {
expect(() => resultValidator(action.name)).not.toThrow();
const validateInput = validator.getValidator(action.inputSchema);
expect(action.examples?.length).toBeGreaterThan(0);
for (const example of action.examples ?? []) {
const result = validateInput(example.params);
expect(result.valid, `${action.name}: ${result.errorMessage}`).toBe(true);
}
});
it('exposes full contracts through get_action and aliases without executing anything', async () => {
const client = { call: vi.fn(), callStream: vi.fn() };
for (const action of ACTIONS) {
for (const name of [action.name, ...action.aliases]) {
const result = await callTool('get_action', { name }, { mode: 'docs', client });
expect(result.structuredContent.result).toEqual(action);
expect(result.content[0]).toEqual({
type: 'text',
text: JSON.stringify(action, null, 2),
});
}
}
expect(client.call).not.toHaveBeenCalled();
expect(client.callStream).not.toHaveBeenCalled();
});
it.each(['list_actions', 'search_actions'])('keeps %s compact', async (name) => {
const result = await callTool(name, { query: 'send_group_msg' }, { mode: 'docs' });
const block = result.content[0];
expect(block.type).toBe('text');
if (block.type !== 'text') {
throw new Error('expected text result');
}
const payload = JSON.parse(block.text);
expect(payload.actions.length).toBeGreaterThan(0);
for (const action of payload.actions) {
expect(action).not.toHaveProperty('examples');
expect(action).not.toHaveProperty('returnsSchema');
}
});
});
describe('result schemas against actual OneBot handlers', () => {
it('preserves signed message IDs and documents the upload-only sentinel separately', async () => {
const api = new ApiHandler(
context({
sendGroupMessage: async () => ({ messageId: -123 }),
sendForwardMsg: async () => ({ forwardId: 'example-forward' }),
}),
);
const send = await api.handle('send_group_msg', { group_id: 20001, message: 'hello' });
expect(send).toEqual({ status: 'ok', retcode: 0, data: { message_id: -123 } });
matchesResult('send_group_msg', send.data);
expect(resultValidator('send_group_msg')({ message_id: 0 }).valid).toBe(false);
for (const name of ['upload_forward_msg', 'upload_foward_msg']) {
const uploaded = await api.handle(name, {
group_id: 20001,
messages: [
{
type: 'node',
data: { user_id: 10001, nickname: 'example', content: 'hello' },
},
],
});
expect(uploaded.status).toBe('ok');
expect(uploaded.data).toMatchObject({ message_id: 0, res_id: 'example-forward' });
matchesResult(name, uploaded.data);
if (name === 'upload_forward_msg') {
expect(uploaded.data).toHaveProperty('group_id', 20001);
} else {
expect(uploaded.data).not.toHaveProperty('group_id');
}
}
});
it('distinguishes null, empty objects, and failed unsupported calls', async () => {
const api = new ApiHandler(
context({
bridge: {
apis: {
friend: { setRemark: async () => {} },
profile: { setInputStatus: async () => {} },
},
},
}),
);
const empty = await api.handle('set_friend_remark', { user_id: 10001, remark: 'example' });
expect(empty.data).toBeNull();
matchesResult('set_friend_remark', empty.data);
expect(resultValidator('set_friend_remark')({}).valid).toBe(false);
const object = await api.handle('set_input_status', { user_id: 10001 });
expect(object.data).toEqual({});
matchesResult('set_input_status', object.data);
expect(resultValidator('set_input_status')(null).valid).toBe(false);
for (const name of ['set_restart', 'create_collection', '.get_word_slices']) {
const failure = await api.handle(name, {});
expect(failure.status).toBe('failed');
expect(resultValidator(name)(failure.data).valid).toBe(false);
}
});
it('documents actual media string sizes and optional converted record fields', async () => {
const media = {
file: 'https://example.com/media',
url: 'https://example.com/media',
file_size: '42',
file_name: 'voice.amr',
};
const api = new ApiHandler(
context({
getImageInfo: async () => media,
getRecordInfo: async () => media,
bridge: {
apis: {
extras: { convertRecord: async () => ({ base64: 'aGVsbG8=', size: 5 }) },
},
},
}),
);
for (const name of ['get_image', 'get_file', 'get_record']) {
const result = await api.handle(name, { file: 'cached-media' });
expect(result.status).toBe('ok');
matchesResult(name, result.data);
expect(resultValidator(name)({ ...media, file_size: 42 }).valid).toBe(false);
}
const converted = await api.handle('get_record', {
file: 'cached-media',
out_format: 'mp3',
});
expect(converted.data).toEqual({ ...media, out_format: 'mp3', base64: 'aGVsbG8=' });
matchesResult('get_record', converted.data);
});
it('describes get_msg data without the event envelope for both conversation types', async () => {
for (const messageType of ['group', 'private']) {
const event: JsonObject = {
post_type: 'message',
self_id: 10002,
message_type: messageType,
message_id: -123,
user_id: 10001,
time: 1700000000,
message: [{ type: 'text', data: { text: 'hello' } }],
raw_message: 'hello',
...(messageType === 'group' ? { group_id: 20001 } : { target_id: 10002 }),
};
const api = new ApiHandler(
context({
getMessage: async () => event,
refreshMessageMedia: async () => {},
}),
);
const result = await api.handle('get_msg', { message_id: -123 });
expect(result.status).toBe('ok');
expect(result.data).toMatchObject({ real_id: -123, message_id: -123 });
expect(result.data).not.toHaveProperty('post_type');
expect(result.data).not.toHaveProperty('self_id');
matchesResult('get_msg', result.data);
}
});
it('describes successful stream frame data, not MCP aggregate results or failed frames', async () => {
const api = new ApiHandler(context());
const frames: JsonValue[] = [];
const response = await api.handle(
'test_download_stream',
{ error: false },
{
send: async (frame) => {
frames.push(frame);
},
},
);
expect(frames).toHaveLength(10);
expect(response.status).toBe('ok');
for (const frame of [...frames, response.data]) {
matchesResult('test_download_stream', frame);
}
expect(
resultValidator('test_download_stream')({ frame_count: 11, frames, terminal: response })
.valid,
).toBe(false);
expect(
resultValidator('test_download_stream')({ type: 'error', data_type: 'error' }).valid,
).toBe(false);
const failure = await api.handle('test_download_stream', { error: true });
expect(failure.status).toBe('failed');
});
});