SnowLuma v2.4.2 source snapshot
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

This commit is contained in:
TanTan committed 2026-11-13 09:02:00 +08:00
commit fa9c73363d
1367 files changed
+355735

No files matched your search

+339
View File
@@ -0,0 +1,339 @@
import { describe, it, expect } from 'vitest';
import { computeTools, callTool, parseMode } from '../src/tools';
import type {
ActionClient,
OneBotEnvelope,
StreamCallOptions,
StreamCallResult,
} from '../src/client';
type Res = Awaited<ReturnType<typeof callTool>>;
function text(res: Res): string {
const block = res.content?.[0];
return block && block.type === 'text' ? block.text : '';
}
function parsed(res: Res): any {
return JSON.parse(text(res));
}
/** A fake ActionClient — no transport. Records the last call and returns a
* canned envelope (overridable per test). */
function fakeClient(
impl?: (action: string, params: Record<string, unknown>) => OneBotEnvelope,
streamImpl?: (
action: string,
params: Record<string, unknown>,
options?: StreamCallOptions,
) => StreamCallResult,
): ActionClient {
return {
call: async (action, params) =>
impl?.(action, params) ?? { status: 'ok', retcode: 0, data: { action, params } },
callStream: async (action, params, options) =>
streamImpl?.(action, params, options) ?? {
frame_count: 1,
frames: [],
terminal: { status: 'ok', retcode: 0, data: { type: 'response', action, params } },
},
};
}
describe('computeTools — mode gating', () => {
it('fails fast on an invalid SNOWLUMA_MCP_MODE', () => {
expect(parseMode(undefined)).toBeUndefined();
expect(parseMode('WRITE')).toBe('write');
for (const invalid of ['', 'unsafe', 'reader']) {
expect(() => parseMode(invalid), invalid).toThrow(/SNOWLUMA_MCP_MODE/);
}
});
it('docs mode exposes catalog tools and local runtime information', () => {
expect(computeTools('docs').map((t) => t.name)).toEqual([
'list_actions',
'get_action',
'search_actions',
'list_categories',
'get_runtime_info',
]);
});
it('read mode adds query_action only (no invoke_action)', () => {
const names = computeTools('read').map((t) => t.name);
expect(names).toContain('query_action');
expect(names).not.toContain('invoke_action');
});
it('write mode adds both query_action and invoke_action', () => {
const names = computeTools('write').map((t) => t.name);
expect(names).toContain('query_action');
expect(names).toContain('invoke_action');
});
it('annotations are honest: query=readOnly, invoke=destructive', () => {
const q = computeTools('write').find((t) => t.name === 'query_action');
const inv = computeTools('write').find((t) => t.name === 'invoke_action');
expect(q?.annotations?.readOnlyHint).toBe(true);
expect(inv?.annotations?.readOnlyHint).toBe(false);
expect(inv?.annotations?.destructiveHint).toBe(true);
});
});
describe('callTool — docs tools (no client needed)', () => {
it('list_actions returns entries that carry readOnly', async () => {
const data = parsed(await callTool('list_actions', {}, { mode: 'docs' }));
expect(data.count).toBeGreaterThan(100);
expect(typeof data.actions[0].readOnly).toBe('boolean');
});
it('get_action resolves a known read-only action', async () => {
const a = parsed(await callTool('get_action', { name: 'get_status' }, { mode: 'docs' }));
expect(a.name).toBe('get_status');
expect(a.readOnly).toBe(true);
});
it('get_action on a write action reports readOnly=false', async () => {
expect(
parsed(await callTool('get_action', { name: 'send_group_msg' }, { mode: 'docs' }))
.readOnly,
).toBe(false);
});
it('get_action accepts an alias', async () => {
// nc_get_rkey is an alias of get_rkey (read-only).
const a = parsed(await callTool('get_action', { name: 'nc_get_rkey' }, { mode: 'docs' }));
expect(a.name).toBe('get_rkey');
});
});
describe('callTool — query_action (read)', () => {
it.each([null, [], 'invalid', 42, false].map((params) => ({ params })))(
'rejects invalid params without invoking the client ($params)',
async ({ params }) => {
let called = false;
const client = fakeClient(() => {
called = true;
return { status: 'ok', retcode: 0, data: null };
});
const res = await callTool(
'query_action',
{ action: 'get_status', params },
{ mode: 'read', client },
);
expect(res.isError).toBe(true);
expect(text(res)).toContain('params');
expect(called).toBe(false);
},
);
it('executes a read-only action and passes the envelope through', async () => {
const res = await callTool(
'query_action',
{ action: 'get_status', params: {} },
{ mode: 'read', client: fakeClient() },
);
expect(res.isError).toBeFalsy();
expect(parsed(res).retcode).toBe(0);
});
it('refuses a write action and points to invoke_action', async () => {
const res = await callTool(
'query_action',
{ action: 'send_group_msg', params: {} },
{ mode: 'read', client: fakeClient() },
);
expect(res.isError).toBe(true);
expect(text(res)).toContain('invoke_action');
});
it('refuses an unknown action', async () => {
const res = await callTool(
'query_action',
{ action: 'no_such_action' },
{ mode: 'read', client: fakeClient() },
);
expect(res.isError).toBe(true);
expect(text(res)).toContain('未知 action');
});
it('is blocked in docs mode even when called directly (defense in depth)', async () => {
const res = await callTool('query_action', { action: 'get_status' }, { mode: 'docs' });
expect(res.isError).toBe(true);
});
it('errors clearly when no client is wired', async () => {
const res = await callTool('query_action', { action: 'get_status' }, { mode: 'read' });
expect(res.isError).toBe(true);
expect(text(res)).toContain('未配置');
});
});
describe('callTool — invoke_action (write)', () => {
it('executes a write action in write mode and forwards action+params', async () => {
let seen: { action: string; params: Record<string, unknown> } | null = null;
const client: ActionClient = {
call: async (action, params) => {
seen = { action, params };
return { status: 'ok', retcode: 0, data: { message_id: 42 } };
},
callStream: async () => {
throw new Error('unexpected stream call');
},
};
const res = await callTool(
'invoke_action',
{ action: 'send_group_msg', params: { group_id: 1, message: 'hi' } },
{ mode: 'write', client },
);
expect(res.isError).toBeFalsy();
expect(seen!.action).toBe('send_group_msg');
expect(seen!.params).toEqual({ group_id: 1, message: 'hi' });
expect(parsed(res).data.message_id).toBe(42);
});
it('is blocked in read mode (write gate cannot be bypassed)', async () => {
const res = await callTool(
'invoke_action',
{ action: 'send_group_msg', params: {} },
{ mode: 'read', client: fakeClient() },
);
expect(res.isError).toBe(true);
expect(text(res)).toContain('写操作未启用');
});
it('passes a retcode≠0 envelope through as data, NOT isError', async () => {
const client = fakeClient(() => ({
status: 'failed',
retcode: 1404,
message: 'not found',
wording: '未找到',
}));
const res = await callTool(
'invoke_action',
{ action: 'send_group_msg', params: {} },
{ mode: 'write', client },
);
expect(res.isError).toBeFalsy();
expect(parsed(res).retcode).toBe(1404);
expect(parsed(res).wording).toBe('未找到');
});
it('maps a transport-level throw to isError', async () => {
const client: ActionClient = {
call: async () => {
throw new Error('connection refused');
},
callStream: async () => {
throw new Error('unexpected stream call');
},
};
const res = await callTool(
'invoke_action',
{ action: 'send_group_msg', params: {} },
{ mode: 'write', client },
);
expect(res.isError).toBe(true);
expect(text(res)).toContain('调用失败');
expect(text(res)).toContain('connection refused');
});
it('accepts a read-only action too (invoke is a superset)', async () => {
const res = await callTool(
'invoke_action',
{ action: 'get_status', params: {} },
{ mode: 'write', client: fakeClient() },
);
expect(res.isError).toBeFalsy();
});
it('refuses an unknown action (typo safety)', async () => {
const res = await callTool(
'invoke_action',
{ action: 'frobnicate' },
{ mode: 'write', client: fakeClient() },
);
expect(res.isError).toBe(true);
expect(text(res)).toContain('未知 action');
});
it('routes catalog Stream Actions through callStream and returns the bounded summary', async () => {
let ordinaryCalls = 0;
let streamCalls = 0;
const terminal = {
status: 'ok',
retcode: 0,
data: { type: 'response', data_type: 'data_complete' },
};
const client: ActionClient = {
call: async () => {
ordinaryCalls++;
return { status: 'ok', retcode: 0 };
},
callStream: async (action, params) => {
streamCalls++;
expect(action).toBe('test_download_stream');
expect(params).toEqual({ error: false });
return { frame_count: 1, frames: [], terminal };
},
};
const res = await callTool(
'invoke_action',
{ action: 'test_download_stream', params: { error: false } },
{ mode: 'write', client },
);
expect(res.isError).toBeFalsy();
expect(parsed(res)).toEqual({ frame_count: 1, frames: [], terminal });
expect(streamCalls).toBe(1);
expect(ordinaryCalls).toBe(0);
});
it('passes execution.input_file only as an MCP upload option, never as OneBot params', async () => {
let seen: { params: Record<string, unknown>; inputFile?: string } | undefined;
const client = fakeClient(undefined, (_action, params, options) => {
seen = { params, inputFile: options?.inputFile };
return {
file_path: '/remote/local.txt',
file_size: 5,
sha256: 'abc',
chunk_count: 1,
stream_id: 'id',
terminal: {
status: 'ok',
retcode: 0,
data: { type: 'response', status: 'file_complete' },
},
};
});
const res = await callTool(
'invoke_action',
{
action: 'upload_file_stream',
params: { file_retention: 0 },
execution: { input_file: '/allowed/local.txt' },
},
{ mode: 'write', client },
);
expect(res.isError).toBeFalsy();
expect(seen).toEqual({ params: { file_retention: 0 }, inputFile: '/allowed/local.txt' });
});
it('forwards MCP request cancellation to Stream Action execution', async () => {
const controller = new AbortController();
let seenSignal: AbortSignal | undefined;
const client = fakeClient(undefined, (_action, _params, options) => {
seenSignal = options?.signal;
return {
frame_count: 1,
frames: [],
terminal: {
status: 'ok',
retcode: 0,
data: { type: 'response', data_type: 'data_complete' },
},
};
});
const res = await callTool(
'invoke_action',
{ action: 'test_download_stream', params: {} },
{
mode: 'write',
client,
signal: controller.signal,
},
);
expect(res.isError).toBeFalsy();
expect(seenSignal).toBe(controller.signal);
});
});