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

+82
View File
@@ -0,0 +1,82 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { createRequestAbortState, raceWithAbort } from '../src/internal/abort';
afterEach(() => vi.useRealTimers());
describe('request cancellation', () => {
it('honors an existing cancellation before a completed operation', async () => {
const controller = new AbortController();
controller.abort('already canceled');
await expect(
raceWithAbort(Promise.resolve('done'), controller.signal),
).rejects.toMatchObject({
cause: 'already canceled',
});
});
it('releases its listener when cancellation wins against a pending operation', async () => {
const operation = Promise.withResolvers<void>();
const controller = new AbortController();
const remove = vi.spyOn(controller.signal, 'removeEventListener');
const result = raceWithAbort(operation.promise, controller.signal);
controller.abort('canceled');
await expect(result).rejects.toMatchObject({ cause: 'canceled' });
expect(remove).toHaveBeenCalledWith('abort', expect.any(Function));
operation.resolve();
});
it.each(['resolve', 'reject'] as const)(
'releases its listener when the operation will %s',
async (outcome) => {
const operation = Promise.withResolvers<string>();
const controller = new AbortController();
const remove = vi.spyOn(controller.signal, 'removeEventListener');
const result = raceWithAbort(operation.promise, controller.signal);
if (outcome === 'resolve') {
operation.resolve('done');
await expect(result).resolves.toBe('done');
} else {
operation.reject(new Error('failed'));
await expect(result).rejects.toThrow('failed');
}
expect(remove).toHaveBeenCalledWith('abort', expect.any(Function));
},
);
it('keeps caller cancellation as the cause when the timeout expires later', () => {
vi.useFakeTimers();
const caller = new AbortController();
const request = createRequestAbortState(100, caller.signal);
caller.abort('caller canceled');
vi.advanceTimersByTime(100);
expect(request.signal.reason).toBe('caller canceled');
expect(request.isExternallyAborted()).toBe(true);
expect(request.isTimedOut()).toBe(false);
request.cleanup();
});
it('keeps the timeout as the cause when the caller cancels later', () => {
vi.useFakeTimers();
const caller = new AbortController();
const request = createRequestAbortState(100, caller.signal);
vi.advanceTimersByTime(100);
caller.abort();
expect(request.isTimedOut()).toBe(true);
expect(request.isExternallyAborted()).toBe(false);
request.cleanup();
});
it('stops observing timeouts and caller cancellation after cleanup', () => {
vi.useFakeTimers();
const caller = new AbortController();
const request = createRequestAbortState(100, caller.signal);
request.cleanup();
caller.abort();
vi.advanceTimersByTime(100);
expect(request.signal.aborted).toBe(false);
expect(request.isTimedOut()).toBe(false);
expect(request.isExternallyAborted()).toBe(false);
});
});
+973
View File
@@ -0,0 +1,973 @@
import { describe, expect, it } from 'vitest';
import { SnowLumaApiClient, toJsonObject } from '../src/client/api-client';
import {
SnowLumaApiError,
SnowLumaAuthError,
text,
type ActionParams,
type ActionResult,
type ApiResponse,
type RequestOptions,
type SnowLumaAction,
} from '../src';
class RecordingClient extends SnowLumaApiClient {
readonly calls: Array<{
action: string;
params: unknown;
options: RequestOptions | undefined;
}> = [];
response: ApiResponse = { status: 'ok', retcode: 0, data: null };
async request<TAction extends SnowLumaAction>(
action: TAction,
params?: ActionParams<TAction>,
options?: RequestOptions,
): Promise<ApiResponse<ActionResult<TAction>>> {
this.calls.push({ action, params, options });
return this.response as ApiResponse<ActionResult<TAction>>;
}
}
function okClient(data: unknown = null): RecordingClient {
const client = new RecordingClient();
client.response = { status: 'ok', retcode: 0, data };
return client;
}
describe('toJsonObject', () => {
it('returns an empty object for undefined', () => {
expect(toJsonObject(undefined)).toEqual({});
});
it('returns the same object when one is provided', () => {
const value = { domain: 'qun.qq.com' };
expect(toJsonObject(value)).toBe(value);
});
});
describe('SnowLumaApiClient.call', () => {
it('preserves friend-list request options while enabling public profile enrichment', async () => {
const client = okClient([]);
await client.getFriendList({ timeoutMs: 60000, withProfile: true });
expect(client.calls[0]).toMatchObject({
action: 'get_friend_list',
params: { with_profile: true },
options: { timeoutMs: 60000 },
});
await client.getFriendList({ timeoutMs: 1000 });
expect(client.calls[1]).toMatchObject({ params: {}, options: { timeoutMs: 1000 } });
});
it('returns response data when status is ok and retcode is 0', async () => {
const client = okClient({ user_id: 1787882683, nickname: 'luma' });
await expect(client.call('get_login_info', {}, { timeoutMs: 1500 })).resolves.toEqual({
user_id: 1787882683,
nickname: 'luma',
});
expect(client.calls).toEqual([
{ action: 'get_login_info', params: {}, options: { timeoutMs: 1500 } },
]);
});
it('throws SnowLumaApiError when status is failed even if retcode is 0', async () => {
const client = new RecordingClient();
const response: ApiResponse = {
status: 'failed',
retcode: 0,
data: null,
wording: 'handler rejected',
};
client.response = response;
await expect(client.call('clean_cache', {})).rejects.toBeInstanceOf(SnowLumaApiError);
await expect(client.call('clean_cache', {})).rejects.toMatchObject({
name: 'SnowLumaApiError',
retcode: 0,
wording: 'handler rejected',
message: 'handler rejected',
response,
});
});
it('throws SnowLumaApiError when retcode is non-zero even if status is ok', async () => {
const client = new RecordingClient();
const response: ApiResponse = { status: 'ok', retcode: 1200, data: null };
client.response = response;
await expect(client.getStatus()).rejects.toBeInstanceOf(SnowLumaApiError);
await expect(client.getStatus()).rejects.toMatchObject({
name: 'SnowLumaApiError',
retcode: 1200,
message: 'SnowLuma API failed with retcode 1200',
response,
});
});
it('throws SnowLumaAuthError for auth retcodes', async () => {
const client = new RecordingClient();
client.response = { status: 'failed', retcode: 1401, data: null, wording: 'unauthorized' };
await expect(client.getStatus()).rejects.toBeInstanceOf(SnowLumaAuthError);
client.response = { status: 'ok', retcode: 401, data: null, wording: 'token missing' };
await expect(client.getStatus()).rejects.toMatchObject({
name: 'SnowLumaAuthError',
retcode: 401,
wording: 'token missing',
});
client.response = { status: 'failed', retcode: 403, data: null };
await expect(client.getStatus()).rejects.toBeInstanceOf(SnowLumaAuthError);
});
});
describe('SnowLumaApiClient.raw', () => {
it('unwraps successful data and forwards the raw action arguments', async () => {
const client = okClient({ cookies: 'uin=o10001' });
await expect(
client.raw('get_cookies', { domain: 'qun.qq.com' }, { echo: 'raw-1' }),
).resolves.toEqual({
cookies: 'uin=o10001',
});
expect(client.calls).toEqual([
{ action: 'get_cookies', params: { domain: 'qun.qq.com' }, options: { echo: 'raw-1' } },
]);
});
it('throws on failed envelopes instead of returning them', async () => {
const client = new RecordingClient();
client.response = { status: 'failed', retcode: 1403, data: null, wording: 'denied' };
await expect(client.raw('get_csrf_token', {})).rejects.toBeInstanceOf(SnowLumaApiError);
});
});
describe('SnowLumaApiClient.rawResponse', () => {
it('returns the full envelope without applying call error handling', async () => {
const client = new RecordingClient();
const response: ApiResponse = {
status: 'failed',
retcode: 1403,
data: null,
wording: 'denied',
};
client.response = response;
await expect(client.rawResponse('get_csrf_token', {}, { echo: 'env-1' })).resolves.toEqual(
response,
);
expect(client.calls).toEqual([
{ action: 'get_csrf_token', params: {}, options: { echo: 'env-1' } },
]);
});
});
describe('SnowLumaApiClient typed actions', () => {
it('exposes account-session uptime through getStatus', async () => {
const client = okClient({ online: true, good: true, time: 90 });
const status = await client.getStatus();
const seconds: number = status.time;
expect(seconds).toBe(90);
});
it('maps session helpers to empty-param OneBot actions', async () => {
const client = okClient();
const timeout = { timeoutMs: 2500 };
await client.getLoginInfo();
await client.getStatus(timeout);
await client.getVersionInfo();
await client.canSendImage();
await client.canSendRecord();
await client.getFriendList();
await client.getRKey();
await client.getCsrfToken();
await client.cleanCache();
await client.markAllAsRead();
await client.checkUrlSafely();
await client.getClientKey();
expect(client.calls).toEqual([
{ action: 'get_login_info', params: {}, options: undefined },
{ action: 'get_status', params: {}, options: timeout },
{ action: 'get_version_info', params: {}, options: undefined },
{ action: 'can_send_image', params: {}, options: undefined },
{ action: 'can_send_record', params: {}, options: undefined },
{ action: 'get_friend_list', params: {}, options: undefined },
{ action: 'get_rkey', params: {}, options: undefined },
{ action: 'get_csrf_token', params: {}, options: undefined },
{ action: 'clean_cache', params: {}, options: undefined },
{ action: '_mark_all_as_read', params: {}, options: undefined },
{ action: 'check_url_safely', params: {}, options: undefined },
{ action: 'get_clientkey', params: {}, options: undefined },
]);
});
it('normalizes outgoing messages for send helpers', async () => {
const client = okClient({ message_id: 88 });
await client.sendMsg({
message_type: 'group',
group_id: 941657197,
message: 'plain',
auto_escape: true,
});
await client.sendMsg({
message_type: 'private',
user_id: 10001,
message: { type: 'text', data: { text: 'seg' } },
});
await client.sendPrivateMessage(10002, text('hi'), { autoEscape: true, timeoutMs: 9 });
await client.sendGroupMessage(20002, [{ type: 'text', data: { text: 'arr' } }]);
await client.sendGroupForwardMessage(30003, text('fwd-g'));
await client.sendPrivateForwardMessage(40004, 'fwd-p');
expect(client.calls).toEqual([
{
action: 'send_msg',
params: {
message_type: 'group',
group_id: 941657197,
message: 'plain',
auto_escape: true,
},
options: undefined,
},
{
action: 'send_msg',
params: {
message_type: 'private',
user_id: 10001,
message: [{ type: 'text', data: { text: 'seg' } }],
},
options: undefined,
},
{
action: 'send_private_msg',
params: {
user_id: 10002,
message: [{ type: 'text', data: { text: 'hi' } }],
auto_escape: true,
},
options: { autoEscape: true, timeoutMs: 9 },
},
{
action: 'send_group_msg',
params: {
group_id: 20002,
message: [{ type: 'text', data: { text: 'arr' } }],
auto_escape: undefined,
},
options: undefined,
},
{
action: 'send_group_forward_msg',
params: {
group_id: 30003,
messages: [{ type: 'text', data: { text: 'fwd-g' } }],
},
options: undefined,
},
{
action: 'send_private_forward_msg',
params: {
user_id: 40004,
messages: 'fwd-p',
},
options: undefined,
},
]);
});
it('maps message lookup, delete, and history helpers', async () => {
const client = okClient();
await client.getMessage(71);
await client.deleteMessage(72);
await client.getGroupMessageHistory({
group_id: 941657197,
message_id: 73,
count: 20,
reverse_order: false,
});
await client.getFriendMessageHistory({ user_id: 10001, count: 8 });
await client.markGroupMessageAsRead(74);
await client.markGroupMessageAsRead(75, 941657198);
await client.markPrivateMessageAsRead(76);
await client.markPrivateMessageAsRead(77, 10002);
await client.markMessageAsRead(78);
await client.markMessageAsRead(79, 10003);
expect(client.calls).toEqual([
{ action: 'get_msg', params: { message_id: 71 }, options: undefined },
{ action: 'delete_msg', params: { message_id: 72 }, options: undefined },
{
action: 'get_group_msg_history',
params: { group_id: 941657197, message_id: 73, count: 20, reverse_order: false },
options: undefined,
},
{
action: 'get_friend_msg_history',
params: { user_id: 10001, count: 8 },
options: undefined,
},
{
action: 'mark_group_msg_as_read',
params: { message_id: 74, group_id: undefined },
options: undefined,
},
{
action: 'mark_group_msg_as_read',
params: { message_id: 75, group_id: 941657198 },
options: undefined,
},
{
action: 'mark_private_msg_as_read',
params: { message_id: 76, user_id: undefined },
options: undefined,
},
{
action: 'mark_private_msg_as_read',
params: { message_id: 77, user_id: 10002 },
options: undefined,
},
{
action: 'mark_msg_as_read',
params: { message_id: 78, target_id: undefined },
options: undefined,
},
{
action: 'mark_msg_as_read',
params: { message_id: 79, target_id: 10003 },
options: undefined,
},
]);
});
it('maps friend and stranger helpers, including optional block', async () => {
const client = okClient();
await client.getStrangerInfo(10011);
await client.deleteFriend(10012);
await client.deleteFriend(10013, { block: true, timeoutMs: 40 });
await client.setFriendRemark(10014, 'alias');
await client.setFriendAddRequest('flag-a');
await client.setFriendAddRequest('flag-b', false);
expect(client.calls).toEqual([
{ action: 'get_stranger_info', params: { user_id: 10011 }, options: undefined },
{
action: 'delete_friend',
params: { user_id: 10012, block: undefined },
options: undefined,
},
{
action: 'delete_friend',
params: { user_id: 10013, block: true },
options: { block: true, timeoutMs: 40 },
},
{
action: 'set_friend_remark',
params: { user_id: 10014, remark: 'alias' },
options: undefined,
},
{
action: 'set_friend_add_request',
params: { flag: 'flag-a', approve: true },
options: undefined,
},
{
action: 'set_friend_add_request',
params: { flag: 'flag-b', approve: false },
options: undefined,
},
]);
});
it('maps group list and member lookups with noCache', async () => {
const client = okClient();
await client.getGroupList();
await client.getGroupList({ noCache: true, timeoutMs: 11 });
await client.getGroupInfo(941657197);
await client.getGroupInfo(941657198, { noCache: false });
await client.getGroupMemberList(941657199);
await client.getGroupMemberList(941657200, { noCache: true });
await client.getGroupMemberInfo(941657201, 10021);
await client.getGroupMemberInfo(941657202, 10022, { noCache: true });
await client.getGroupInfoEx(941657203);
await client.getGroupInfoEx(941657204, { noCache: true });
await client.getGroupDetailInfo(941657205);
await client.getGroupDetailInfo(941657206, { noCache: true });
expect(client.calls).toEqual([
{ action: 'get_group_list', params: { no_cache: undefined }, options: undefined },
{
action: 'get_group_list',
params: { no_cache: true },
options: { noCache: true, timeoutMs: 11 },
},
{
action: 'get_group_info',
params: { group_id: 941657197, no_cache: undefined },
options: undefined,
},
{
action: 'get_group_info',
params: { group_id: 941657198, no_cache: false },
options: { noCache: false },
},
{
action: 'get_group_member_list',
params: { group_id: 941657199, no_cache: undefined },
options: undefined,
},
{
action: 'get_group_member_list',
params: { group_id: 941657200, no_cache: true },
options: { noCache: true },
},
{
action: 'get_group_member_info',
params: { group_id: 941657201, user_id: 10021, no_cache: undefined },
options: undefined,
},
{
action: 'get_group_member_info',
params: { group_id: 941657202, user_id: 10022, no_cache: true },
options: { noCache: true },
},
{
action: 'get_group_info_ex',
params: { group_id: 941657203, no_cache: undefined },
options: undefined,
},
{
action: 'get_group_info_ex',
params: { group_id: 941657204, no_cache: true },
options: { noCache: true },
},
{
action: 'get_group_detail_info',
params: { group_id: 941657205, no_cache: undefined },
options: undefined,
},
{
action: 'get_group_detail_info',
params: { group_id: 941657206, no_cache: true },
options: { noCache: true },
},
]);
});
it('maps honor and system-message helpers', async () => {
const client = okClient();
await client.getGroupHonorInfo({ group_id: 941657210, type: 'talkative' });
await client.getGroupSystemMessages();
await client.getGroupSystemMessages({ groupId: 941657211, onlyPending: true, count: 5 });
expect(client.calls).toEqual([
{
action: 'get_group_honor_info',
params: { group_id: 941657210, type: 'talkative' },
options: undefined,
},
{
action: 'get_group_system_msg',
params: { group_id: undefined, only_pending: undefined, count: undefined },
options: undefined,
},
{
action: 'get_group_system_msg',
params: { group_id: 941657211, only_pending: true, count: 5 },
options: { groupId: 941657211, onlyPending: true, count: 5 },
},
]);
});
it('maps group administration helpers and their defaults', async () => {
const client = okClient();
await client.setGroupKick(941657220, 10031);
await client.setGroupKick(941657221, 10032, { rejectAddRequest: true });
await client.setGroupBan(941657222, 10033);
await client.setGroupBan(941657223, 10034, 600);
await client.setGroupWholeBan(941657224);
await client.setGroupWholeBan(941657225, false);
await client.setGroupAdmin(941657226, 10035);
await client.setGroupAdmin(941657227, 10036, false);
await client.setGroupCard(941657228, 10037);
await client.setGroupCard(941657229, 10038, 'card');
await client.setGroupName(941657230, 'new-name');
await client.setGroupLeave(941657231);
await client.setGroupSpecialTitle(941657232, 10039);
await client.setGroupSpecialTitle(941657233, 10040, 'title');
expect(client.calls).toEqual([
{
action: 'set_group_kick',
params: { group_id: 941657220, user_id: 10031, reject_add_request: undefined },
options: undefined,
},
{
action: 'set_group_kick',
params: { group_id: 941657221, user_id: 10032, reject_add_request: true },
options: { rejectAddRequest: true },
},
{
action: 'set_group_ban',
params: { group_id: 941657222, user_id: 10033, duration: undefined },
options: undefined,
},
{
action: 'set_group_ban',
params: { group_id: 941657223, user_id: 10034, duration: 600 },
options: undefined,
},
{
action: 'set_group_whole_ban',
params: { group_id: 941657224, enable: true },
options: undefined,
},
{
action: 'set_group_whole_ban',
params: { group_id: 941657225, enable: false },
options: undefined,
},
{
action: 'set_group_admin',
params: { group_id: 941657226, user_id: 10035, enable: true },
options: undefined,
},
{
action: 'set_group_admin',
params: { group_id: 941657227, user_id: 10036, enable: false },
options: undefined,
},
{
action: 'set_group_card',
params: { group_id: 941657228, user_id: 10037, card: '' },
options: undefined,
},
{
action: 'set_group_card',
params: { group_id: 941657229, user_id: 10038, card: 'card' },
options: undefined,
},
{
action: 'set_group_name',
params: { group_id: 941657230, group_name: 'new-name' },
options: undefined,
},
{ action: 'set_group_leave', params: { group_id: 941657231 }, options: undefined },
{
action: 'set_group_special_title',
params: { group_id: 941657232, user_id: 10039, special_title: '' },
options: undefined,
},
{
action: 'set_group_special_title',
params: { group_id: 941657233, user_id: 10040, special_title: 'title' },
options: undefined,
},
]);
});
it('maps group and private file helpers', async () => {
const client = okClient();
await client.uploadGroupFile(941657240, '/tmp/a.png');
await client.uploadGroupFile(941657241, '/tmp/b.png', {
name: 'b.png',
folder: '/docs',
folderId: '/abc',
uploadFile: true,
});
await client.uploadPrivateFile(10051, '/tmp/c.png');
await client.uploadPrivateFile(10052, '/tmp/d.png', { name: 'd.png', uploadFile: false });
await client.getGroupFileUrl(941657242, 'file-1');
await client.getGroupFileUrl(941657243, 'file-2', { busid: 102 });
await client.getGroupRootFiles(941657244);
await client.getGroupFilesByFolder(941657245);
await client.getGroupFilesByFolder(941657246, '/sub');
await client.deleteGroupFile(941657247, 'file-3');
await client.moveGroupFile(941657248, 'file-4', '/from', '/to');
await client.createGroupFileFolder(941657249, 'photos');
await client.createGroupFileFolder(941657250, 'docs', '/root');
await client.deleteGroupFileFolder(941657251, '/gone');
await client.renameGroupFileFolder(941657252, '/old', 'new-name');
await client.getPrivateFileUrl(10053, 'file-5', 'hash-5');
await client.getGroupFileSystemInfo(941657253);
expect(client.calls).toEqual([
{
action: 'upload_group_file',
params: {
group_id: 941657240,
file: '/tmp/a.png',
name: undefined,
folder: undefined,
folder_id: undefined,
upload_file: undefined,
},
options: {},
},
{
action: 'upload_group_file',
params: {
group_id: 941657241,
file: '/tmp/b.png',
name: 'b.png',
folder: '/docs',
folder_id: '/abc',
upload_file: true,
},
options: { name: 'b.png', folder: '/docs', folderId: '/abc', uploadFile: true },
},
{
action: 'upload_private_file',
params: {
user_id: 10051,
file: '/tmp/c.png',
name: undefined,
upload_file: undefined,
},
options: {},
},
{
action: 'upload_private_file',
params: { user_id: 10052, file: '/tmp/d.png', name: 'd.png', upload_file: false },
options: { name: 'd.png', uploadFile: false },
},
{
action: 'get_group_file_url',
params: { group_id: 941657242, file_id: 'file-1', busid: undefined },
options: undefined,
},
{
action: 'get_group_file_url',
params: { group_id: 941657243, file_id: 'file-2', busid: 102 },
options: { busid: 102 },
},
{ action: 'get_group_root_files', params: { group_id: 941657244 }, options: undefined },
{
action: 'get_group_files_by_folder',
params: { group_id: 941657245, folder_id: '/' },
options: undefined,
},
{
action: 'get_group_files_by_folder',
params: { group_id: 941657246, folder_id: '/sub' },
options: undefined,
},
{
action: 'delete_group_file',
params: { group_id: 941657247, file_id: 'file-3' },
options: undefined,
},
{
action: 'move_group_file',
params: {
group_id: 941657248,
file_id: 'file-4',
parent_directory: '/from',
target_directory: '/to',
},
options: undefined,
},
{
action: 'create_group_file_folder',
params: { group_id: 941657249, name: 'photos', parent_id: '/' },
options: undefined,
},
{
action: 'create_group_file_folder',
params: { group_id: 941657250, name: 'docs', parent_id: '/root' },
options: undefined,
},
{
action: 'delete_group_file_folder',
params: { group_id: 941657251, folder_id: '/gone' },
options: undefined,
},
{
action: 'rename_group_file_folder',
params: { group_id: 941657252, folder_id: '/old', new_folder_name: 'new-name' },
options: undefined,
},
{
action: 'get_private_file_url',
params: { user_id: 10053, file_id: 'file-5', file_hash: 'hash-5' },
options: undefined,
},
{
action: 'get_group_file_system_info',
params: { group_id: 941657253 },
options: undefined,
},
]);
});
it('maps group add-request, poke, like, and essence helpers', async () => {
const client = okClient();
await client.setGroupAddRequest('flag-c');
await client.setGroupAddRequest('flag-d', {
subType: 'add',
type: 'group',
approve: false,
reason: 'nope',
});
await client.sendLike(10061);
await client.sendLike(10062, 5);
await client.friendPoke(10063);
await client.friendPoke(10064, { targetId: 10065 });
await client.groupPoke(941657260, 10066);
await client.sendPoke(10067);
await client.sendPoke(10068, { groupId: 941657261 });
await client.setEssenceMessage(81);
await client.deleteEssenceMessage(82);
await client.getEssenceMessageList(941657262);
await client.setGroupReaction({
message_id: 83,
code: '76',
group_id: 941657263,
is_set: true,
});
expect(client.calls).toEqual([
{
action: 'set_group_add_request',
params: {
flag: 'flag-c',
sub_type: undefined,
type: undefined,
approve: undefined,
reason: undefined,
},
options: {},
},
{
action: 'set_group_add_request',
params: {
flag: 'flag-d',
sub_type: 'add',
type: 'group',
approve: false,
reason: 'nope',
},
options: { subType: 'add', type: 'group', approve: false, reason: 'nope' },
},
{ action: 'send_like', params: { user_id: 10061, times: 1 }, options: undefined },
{ action: 'send_like', params: { user_id: 10062, times: 5 }, options: undefined },
{
action: 'friend_poke',
params: { user_id: 10063, target_id: undefined },
options: undefined,
},
{
action: 'friend_poke',
params: { user_id: 10064, target_id: 10065 },
options: { targetId: 10065 },
},
{
action: 'group_poke',
params: { group_id: 941657260, user_id: 10066 },
options: undefined,
},
{
action: 'send_poke',
params: { user_id: 10067, group_id: undefined },
options: undefined,
},
{
action: 'send_poke',
params: { user_id: 10068, group_id: 941657261 },
options: { groupId: 941657261 },
},
{ action: 'set_essence_msg', params: { message_id: 81 }, options: undefined },
{ action: 'delete_essence_msg', params: { message_id: 82 }, options: undefined },
{ action: 'get_essence_msg_list', params: { group_id: 941657262 }, options: undefined },
{
action: 'set_group_reaction',
params: { message_id: 83, code: '76', group_id: 941657263, is_set: true },
options: undefined,
},
]);
});
it('maps group-notice helpers to underscored OneBot actions', async () => {
const client = okClient();
await client.sendGroupNotice(941657270, 'welcome');
await client.sendGroupNotice(941657271, 'pinned', {
image: 'https://example.test/n.png',
pinned: 1,
type: 20,
sendToNewMembers: true,
isShowEditCard: 0,
tipWindowType: 1,
confirmRequired: 0,
});
await client.getGroupNotice(941657272);
expect(client.calls).toEqual([
{
action: '_send_group_notice',
params: {
group_id: 941657270,
content: 'welcome',
image: undefined,
pinned: undefined,
type: undefined,
send_to_new_members: undefined,
is_show_edit_card: undefined,
tip_window_type: undefined,
confirm_required: undefined,
},
options: {},
},
{
action: '_send_group_notice',
params: {
group_id: 941657271,
content: 'pinned',
image: 'https://example.test/n.png',
pinned: 1,
type: 20,
send_to_new_members: true,
is_show_edit_card: 0,
tip_window_type: 1,
confirm_required: 0,
},
options: {
image: 'https://example.test/n.png',
pinned: 1,
type: 20,
sendToNewMembers: true,
isShowEditCard: 0,
tipWindowType: 1,
confirmRequired: 0,
},
},
{ action: '_get_group_notice', params: { group_id: 941657272 }, options: undefined },
]);
});
it('forwards forward-message and media helpers without remapping their params', async () => {
const client = okClient();
await client.uploadForwardMessage({ group_id: 941657280, messages: 'up' });
await client.sendForwardMessage({ user_id: 10071, message: 'send' });
await client.getForwardMessage({ id: 'fwd-1' });
await client.getImage({ file: 'img-1' });
await client.getRecord({ file_id: 'rec-1' });
await client.downloadFile({ url: 'https://example.test/a.bin', name: 'a.bin' });
expect(client.calls).toEqual([
{
action: 'upload_forward_msg',
params: { group_id: 941657280, messages: 'up' },
options: undefined,
},
{
action: 'send_forward_msg',
params: { user_id: 10071, message: 'send' },
options: undefined,
},
{ action: 'get_forward_msg', params: { id: 'fwd-1' }, options: undefined },
{ action: 'get_image', params: { file: 'img-1' }, options: undefined },
{ action: 'get_record', params: { file_id: 'rec-1' }, options: undefined },
{
action: 'download_file',
params: { url: 'https://example.test/a.bin', name: 'a.bin' },
options: undefined,
},
]);
});
it('maps cookie, credential, profile, and status helpers', async () => {
const client = okClient();
await client.getCookies();
await client.getCookies({ domain: 'qun.qq.com' });
await client.getCredentials();
await client.getCredentials({ domain: 'qzone.qq.com' });
await client.setQqProfile({ nickname: 'luma', personalNote: 'hello' });
await client.setOnlineStatus(11);
await client.setOnlineStatus(10, { extStatus: 1000, batteryStatus: 80, timeoutMs: 30 });
expect(client.calls).toEqual([
{ action: 'get_cookies', params: {}, options: undefined },
{ action: 'get_cookies', params: { domain: 'qun.qq.com' }, options: undefined },
{ action: 'get_credentials', params: {}, options: undefined },
{ action: 'get_credentials', params: { domain: 'qzone.qq.com' }, options: undefined },
{
action: 'set_qq_profile',
params: { nickname: 'luma', personal_note: 'hello' },
options: undefined,
},
{
action: 'set_online_status',
params: { status: 11, ext_status: undefined, battery_status: undefined },
options: {},
},
{
action: 'set_online_status',
params: { status: 10, ext_status: 1000, battery_status: 80 },
options: { extStatus: 1000, batteryStatus: 80, timeoutMs: 30 },
},
]);
});
it('maps emoji and system-face helpers including refresh defaults', async () => {
const client = okClient();
await client.setMsgEmojiLike(91, '128076');
await client.setMsgEmojiLike(92, '128077', false);
await client.fetchSysFaces();
await client.fetchSysFaces(true);
await client.fetchFaceEntity(392);
await client.fetchFaceEntity(393, { refresh: true });
await client.searchSysFaces('dragon');
await client.fetchSuperFaceId(394);
await client.fetchSuperFaceId(395, { refresh: false });
expect(client.calls).toEqual([
{
action: 'set_msg_emoji_like',
params: { message_id: 91, emoji_id: '128076', set: true },
options: undefined,
},
{
action: 'set_msg_emoji_like',
params: { message_id: 92, emoji_id: '128077', set: false },
options: undefined,
},
{ action: 'fetch_sys_faces', params: { refresh: false }, options: undefined },
{ action: 'fetch_sys_faces', params: { refresh: true }, options: undefined },
{ action: 'fetch_face_entity', params: { face_id: 392, refresh: false }, options: {} },
{
action: 'fetch_face_entity',
params: { face_id: 393, refresh: true },
options: { refresh: true },
},
{ action: 'search_sys_faces', params: { query: 'dragon' }, options: undefined },
{
action: 'fetch_super_face_id',
params: { face_id: 394, refresh: false },
options: {},
},
{
action: 'fetch_super_face_id',
params: { face_id: 395, refresh: false },
options: { refresh: false },
},
]);
});
});
+532
View File
@@ -0,0 +1,532 @@
import { describe, expect, it, vi } from 'vitest';
import {
at,
atAll,
br,
chain,
contact,
face,
forward,
image,
json,
location,
MessageChain,
music,
node,
normalizeMessage,
poke,
raw,
record,
reply,
share,
text,
video,
xml,
} from '../src';
function sender() {
return {
sendGroupMessage: vi.fn().mockResolvedValue({ message_id: 61001 }),
sendPrivateMessage: vi.fn().mockResolvedValue({ message_id: 61002 }),
};
}
describe('MessageChain.empty and chain()', () => {
it('starts empty', () => {
const empty = MessageChain.empty();
expect(empty).toBeInstanceOf(MessageChain);
expect(empty.length).toBe(0);
expect(empty.isEmpty).toBe(true);
expect(empty.toSegments()).toEqual([]);
expect(chain().toSegments()).toEqual([]);
});
it('updates length and isEmpty after a segment is added', () => {
const next = chain().text('wave');
expect(next.length).toBe(1);
expect(next.isEmpty).toBe(false);
expect(chain().isEmpty).toBe(true);
});
it('does not mutate the previous chain when a segment is added', () => {
const base = text('keep');
const next = base.face(39);
expect(base.toSegments()).toEqual([{ type: 'text', data: { text: 'keep' } }]);
expect(next.toSegments()).toEqual([
{ type: 'text', data: { text: 'keep' } },
{ type: 'face', data: { id: '39' } },
]);
});
});
describe('segment builders', () => {
it('builds a text segment and defaults to an empty string', () => {
expect(text().toSegments()).toEqual([{ type: 'text', data: { text: '' } }]);
expect(text('wave').toSegments()).toEqual([{ type: 'text', data: { text: 'wave' } }]);
expect(chain().text().toSegments()).toEqual([{ type: 'text', data: { text: '' } }]);
});
it('treats br as a newline text segment', () => {
expect(br().toSegments()).toEqual([{ type: 'text', data: { text: '\n' } }]);
});
it('stringifies face ids', () => {
expect(face(39).toSegments()).toEqual([{ type: 'face', data: { id: '39' } }]);
expect(face('178').toSegments()).toEqual([{ type: 'face', data: { id: '178' } }]);
});
it('stringifies at targets and only copies truthy name/uid', () => {
expect(at(31415).toSegments()).toEqual([{ type: 'at', data: { qq: '31415' } }]);
expect(at('all').toSegments()).toEqual([{ type: 'at', data: { qq: 'all' } }]);
expect(atAll().toSegments()).toEqual([{ type: 'at', data: { qq: 'all' } }]);
expect(at(31415, { name: 'Ada', uid: 'uid-ada' }).toSegments()).toEqual([
{ type: 'at', data: { qq: '31415', name: 'Ada', uid: 'uid-ada' } },
]);
expect(at(31415, { name: '', uid: '' }).toSegments()).toEqual([
{ type: 'at', data: { qq: '31415' } },
]);
});
it('stringifies reply ids', () => {
expect(reply(88).toSegments()).toEqual([{ type: 'reply', data: { id: '88' } }]);
expect(reply('mid-88').toSegments()).toEqual([{ type: 'reply', data: { id: 'mid-88' } }]);
});
it('spreads image, record, and video options after file', () => {
expect(image('file://pic.webp').toSegments()).toEqual([
{ type: 'image', data: { file: 'file://pic.webp' } },
]);
expect(
image('file://pic.webp', {
type: 'flash',
subType: 4,
summary: 'cover',
url: 'https://cdn.example/pic.webp',
}).toSegments(),
).toEqual([
{
type: 'image',
data: {
file: 'file://pic.webp',
type: 'flash',
subType: 4,
summary: 'cover',
url: 'https://cdn.example/pic.webp',
},
},
]);
expect(record('file://voice.amr').toSegments()).toEqual([
{ type: 'record', data: { file: 'file://voice.amr' } },
]);
expect(
record('file://voice.amr', { url: 'https://cdn.example/voice.amr' }).toSegments(),
).toEqual([
{
type: 'record',
data: { file: 'file://voice.amr', url: 'https://cdn.example/voice.amr' },
},
]);
expect(video('file://clip.mp4').toSegments()).toEqual([
{ type: 'video', data: { file: 'file://clip.mp4' } },
]);
expect(
video('file://clip.mp4', {
url: 'https://cdn.example/clip.mp4',
thumb: 'file://thumb.jpg',
}).toSegments(),
).toEqual([
{
type: 'video',
data: {
file: 'file://clip.mp4',
url: 'https://cdn.example/clip.mp4',
thumb: 'file://thumb.jpg',
},
},
]);
});
it('keeps a json string and stringifies a json object', () => {
expect(json('{"prompt":"card"}').toSegments()).toEqual([
{ type: 'json', data: { data: '{"prompt":"card"}' } },
]);
expect(json({ prompt: 'card', n: 2 }).toSegments()).toEqual([
{ type: 'json', data: { data: '{"prompt":"card","n":2}' } },
]);
});
it('spreads xml options after the payload', () => {
expect(xml('<item />').toSegments()).toEqual([{ type: 'xml', data: { data: '<item />' } }]);
expect(xml('<item />', { id: 'resid-3' }).toSegments()).toEqual([
{ type: 'xml', data: { data: '<item />', id: 'resid-3' } },
]);
});
it('omits poke id only when it is undefined', () => {
expect(poke(6).toSegments()).toEqual([{ type: 'poke', data: { type: 6 } }]);
expect(poke(6, 0).toSegments()).toEqual([{ type: 'poke', data: { type: 6, id: 0 } }]);
expect(poke('6', '-1').toSegments()).toEqual([
{ type: 'poke', data: { type: '6', id: '-1' } },
]);
});
it('mirrors a forward id onto res_id and forward_id', () => {
expect(forward('fwd-22').toSegments()).toEqual([
{ type: 'forward', data: { id: 'fwd-22', res_id: 'fwd-22', forward_id: 'fwd-22' } },
]);
});
it('stores node content as given', () => {
const inner = text('nested');
expect(node(20002, 'bot', 'plain').toSegments()).toEqual([
{ type: 'node', data: { user_id: 20002, nickname: 'bot', content: 'plain' } },
]);
expect(node(20002, 'bot', { type: 'text', data: { text: 'seg' } }).toSegments()).toEqual([
{
type: 'node',
data: {
user_id: 20002,
nickname: 'bot',
content: { type: 'text', data: { text: 'seg' } },
},
},
]);
expect(node(20002, 'bot', inner).toSegments()).toEqual([
{ type: 'node', data: { user_id: 20002, nickname: 'bot', content: inner } },
]);
});
it('passes share, music, location, and contact data through', () => {
expect(share({ url: 'https://ex.test/page', title: 'Page' }).toSegments()).toEqual([
{ type: 'share', data: { url: 'https://ex.test/page', title: 'Page' } },
]);
expect(
share({
url: 'https://ex.test/page',
title: 'Page',
content: 'blurb',
image: 'https://ex.test/cover.png',
}).toSegments(),
).toEqual([
{
type: 'share',
data: {
url: 'https://ex.test/page',
title: 'Page',
content: 'blurb',
image: 'https://ex.test/cover.png',
},
},
]);
expect(music({ type: 'qq', id: '991' }).toSegments()).toEqual([
{ type: 'music', data: { type: 'qq', id: '991' } },
]);
expect(
location({
lat: 30.1,
lon: 120.2,
title: 'Hangzhou',
content: 'West Lake',
}).toSegments(),
).toEqual([
{
type: 'location',
data: { lat: 30.1, lon: 120.2, title: 'Hangzhou', content: 'West Lake' },
},
]);
expect(contact('qq', 20002).toSegments()).toEqual([
{ type: 'contact', data: { type: 'qq', id: 20002 } },
]);
expect(contact('group', '987654').toSegments()).toEqual([
{ type: 'contact', data: { type: 'group', id: '987654' } },
]);
});
it('builds an arbitrary raw segment', () => {
expect(raw('dice', { result: 3 }).toSegments()).toEqual([
{ type: 'dice', data: { result: 3 } },
]);
});
it('chains fluent builders in order', () => {
const built = chain().text('ping').at(20002).br().image('file://b.jpg').face(39);
expect(built.length).toBe(5);
expect(built.toSegments()).toEqual([
{ type: 'text', data: { text: 'ping' } },
{ type: 'at', data: { qq: '20002' } },
{ type: 'text', data: { text: '\n' } },
{ type: 'image', data: { file: 'file://b.jpg' } },
{ type: 'face', data: { id: '39' } },
]);
});
});
describe('append', () => {
it('appends a string as a text segment', () => {
expect(text('pre').append('post').toSegments()).toEqual([
{ type: 'text', data: { text: 'pre' } },
{ type: 'text', data: { text: 'post' } },
]);
expect(text('pre').append('').toSegments()).toEqual([
{ type: 'text', data: { text: 'pre' } },
{ type: 'text', data: { text: '' } },
]);
});
it('returns the same instance when appending an empty array', () => {
const base = text('keep');
expect(base.append([])).toBe(base);
});
it('appends a single segment', () => {
expect(
text('pre')
.append({ type: 'face', data: { id: '39' } })
.toSegments(),
).toEqual([
{ type: 'text', data: { text: 'pre' } },
{ type: 'face', data: { id: '39' } },
]);
});
it('appends a segment array', () => {
expect(
text('pre')
.append([
{ type: 'at', data: { qq: 'all' } },
{ type: 'face', data: { id: '39' } },
])
.toSegments(),
).toEqual([
{ type: 'text', data: { text: 'pre' } },
{ type: 'at', data: { qq: 'all' } },
{ type: 'face', data: { id: '39' } },
]);
});
it('appends another chain by copying its segments', () => {
const extra = at(20002).text('!');
expect(text('pre').append(extra).toSegments()).toEqual([
{ type: 'text', data: { text: 'pre' } },
{ type: 'at', data: { qq: '20002' } },
{ type: 'text', data: { text: '!' } },
]);
expect(extra.toSegments()).toEqual([
{ type: 'at', data: { qq: '20002' } },
{ type: 'text', data: { text: '!' } },
]);
});
it('appends a duck-typed chain-like value via toSegments', () => {
const duck = {
length: 1,
toSegments() {
return [{ type: 'poke', data: { type: 6 } }];
},
toJSON() {
return this.toSegments();
},
};
expect(text('pre').append(duck).toSegments()).toEqual([
{ type: 'text', data: { text: 'pre' } },
{ type: 'poke', data: { type: 6 } },
]);
});
});
describe('single-use reply', () => {
it('throws when reply is added twice through reply()', () => {
const once = reply(88);
expect(() => (once as unknown as { reply(id: number): unknown }).reply(89)).toThrow(
'Message segment "reply" can only appear once in a chain',
);
});
it('throws when a second reply is appended', () => {
expect(() => reply(88).append({ type: 'reply', data: { id: '89' } })).toThrow(
'Message segment "reply" can only appear once in a chain',
);
expect(() =>
chain().append([
{ type: 'reply', data: { id: '88' } },
{ type: 'text', data: { text: 'x' } },
{ type: 'reply', data: { id: '89' } },
]),
).toThrow('Message segment "reply" can only appear once in a chain');
});
it('throws when raw reply is added twice', () => {
expect(() => raw('reply', { id: '88' }).raw('reply', { id: '89' })).toThrow(
'Message segment "reply" can only appear once in a chain',
);
});
it('allows two independent chains to each have their own reply', () => {
const base = text('fork');
expect(base.reply(88).toSegments()).toEqual([
{ type: 'text', data: { text: 'fork' } },
{ type: 'reply', data: { id: '88' } },
]);
expect(base.reply(89).toSegments()).toEqual([
{ type: 'text', data: { text: 'fork' } },
{ type: 'reply', data: { id: '89' } },
]);
});
it('allows non-reply types, including raw, to appear more than once', () => {
expect(image('a.webp').image('b.webp').toSegments()).toEqual([
{ type: 'image', data: { file: 'a.webp' } },
{ type: 'image', data: { file: 'b.webp' } },
]);
expect(raw('dice', { result: 1 }).raw('dice', { result: 2 }).toSegments()).toEqual([
{ type: 'dice', data: { result: 1 } },
{ type: 'dice', data: { result: 2 } },
]);
});
it('still allows other segments after a reply', () => {
expect(reply(88).text('after').toSegments()).toEqual([
{ type: 'reply', data: { id: '88' } },
{ type: 'text', data: { text: 'after' } },
]);
});
});
describe('serialization and iteration', () => {
it('returns a shallow copy from build, toArray, toSegments, and toJSON', () => {
const built = text('copy').at(20002);
const viaBuild = built.build();
const viaArray = built.toArray();
const viaSegments = built.toSegments();
const viaJson = built.toJSON();
const expected = [
{ type: 'text', data: { text: 'copy' } },
{ type: 'at', data: { qq: '20002' } },
];
expect(viaBuild).toEqual(expected);
expect(viaArray).toEqual(expected);
expect(viaSegments).toEqual(expected);
expect(viaJson).toEqual(expected);
expect(viaBuild).not.toBe(viaArray);
expect(viaArray).not.toBe(viaSegments);
expect(viaSegments).not.toBe(viaJson);
viaBuild.push({ type: 'face', data: { id: '1' } });
expect(built.toSegments()).toEqual(expected);
expect(built.length).toBe(2);
});
it('uses toJSON when stringified', () => {
expect(JSON.parse(JSON.stringify(text('copy').at(20002)))).toEqual([
{ type: 'text', data: { text: 'copy' } },
{ type: 'at', data: { qq: '20002' } },
]);
});
it('renders CQ text through toString', () => {
expect(chain().toString()).toBe('');
expect(text('A&B[C]').toString()).toBe('A&amp;B&#91;C&#93;');
expect(text('wave').at(20002).face(39).toString()).toBe(
'wave[CQ:at,qq=20002][CQ:face,id=39]',
);
expect(image('a,b').toString()).toBe('[CQ:image,file=a&#44;b]');
expect(raw('dice', {}).toString()).toBe('[CQ:dice]');
expect(
chain()
.append({ type: 'image', data: { file: 'z.webp', url: undefined } })
.toString(),
).toBe('[CQ:image,file=z.webp]');
});
it('iterates stored segments', () => {
const built = text('wave').face(39);
expect([...built]).toEqual([
{ type: 'text', data: { text: 'wave' } },
{ type: 'face', data: { id: '39' } },
]);
const walked: unknown[] = [];
for (const segment of built) {
walked.push(segment);
}
expect(walked).toEqual([
{ type: 'text', data: { text: 'wave' } },
{ type: 'face', data: { id: '39' } },
]);
});
});
describe('sendToGroup and sendToPrivate', () => {
it('forwards the chain instance and options to the client', async () => {
const client = sender();
const groupPayload = text('group-hi');
const privatePayload = text('priv-hi');
const options = { autoEscape: true, timeoutMs: 2500, echo: 'echo-chain' };
await expect(groupPayload.sendToGroup(client, 70001, options)).resolves.toEqual({
message_id: 61001,
});
await expect(privatePayload.sendToPrivate(client, 80001, options)).resolves.toEqual({
message_id: 61002,
});
expect(client.sendGroupMessage).toHaveBeenCalledTimes(1);
expect(client.sendGroupMessage).toHaveBeenCalledWith(70001, groupPayload, options);
expect(client.sendPrivateMessage).toHaveBeenCalledTimes(1);
expect(client.sendPrivateMessage).toHaveBeenCalledWith(80001, privatePayload, options);
});
it('passes undefined options when none are given', async () => {
const client = sender();
const payload = text('plain');
await payload.sendToGroup(client, 70002);
await payload.sendToPrivate(client, 80002);
expect(client.sendGroupMessage).toHaveBeenCalledWith(70002, payload, undefined);
expect(client.sendPrivateMessage).toHaveBeenCalledWith(80002, payload, undefined);
});
});
describe('normalizeMessage', () => {
it('returns a string unchanged', () => {
expect(normalizeMessage('plain-text')).toBe('plain-text');
expect(normalizeMessage('')).toBe('');
});
it('returns toSegments() for a chain and for duck-typed chain-like values', () => {
expect(normalizeMessage(text('wave').at(20002))).toEqual([
{ type: 'text', data: { text: 'wave' } },
{ type: 'at', data: { qq: '20002' } },
]);
const segs = [{ type: 'face', data: { id: '39' } }];
const duck = { toSegments: () => segs };
expect(normalizeMessage(duck)).toBe(segs);
});
it('returns an array input by reference', () => {
const segs = [{ type: 'text', data: { text: 'arr' } }];
expect(normalizeMessage(segs)).toBe(segs);
});
it('wraps a single segment in a new array', () => {
const segment = { type: 'text', data: { text: 'solo' } };
expect(normalizeMessage(segment)).toEqual([segment]);
expect(normalizeMessage(segment)).not.toBe(segment);
});
it('does not treat an object with a non-function toSegments as a chain', () => {
const value = { type: 'text', data: { text: 'solo' }, toSegments: 0 };
expect(normalizeMessage(value as never)).toEqual([value]);
});
});
+338
View File
@@ -0,0 +1,338 @@
import { describe, expect, it } from 'vitest';
import { Emitter } from '../src/emitter';
type TestEvents = {
open: undefined;
data: string;
count: number;
close: { code: number; reason: string };
};
describe('Emitter', () => {
it('delivers a payload to a listener registered with on', () => {
const emitter = new Emitter<TestEvents>();
const seen: string[] = [];
emitter.on('data', (payload) => {
seen.push(payload);
});
emitter.emit('data', 'hello');
expect(seen).toEqual(['hello']);
});
it('invokes every listener for the same event in registration order', () => {
const emitter = new Emitter<TestEvents>();
const seen: string[] = [];
emitter.on('data', (payload) => {
seen.push(`first:${payload}`);
});
emitter.on('data', (payload) => {
seen.push(`second:${payload}`);
});
emitter.emit('data', 'alpha');
expect(seen).toEqual(['first:alpha', 'second:alpha']);
});
it('keeps listeners for different events isolated', () => {
const emitter = new Emitter<TestEvents>();
const data: string[] = [];
const counts: number[] = [];
emitter.on('data', (payload) => {
data.push(payload);
});
emitter.on('count', (payload) => {
counts.push(payload);
});
emitter.emit('data', 'alpha');
emitter.emit('count', 7);
emitter.emit('data', 'beta');
expect(data).toEqual(['alpha', 'beta']);
expect(counts).toEqual([7]);
});
it('passes the payload object through by reference', () => {
const emitter = new Emitter<TestEvents>();
const payload = { code: 1000, reason: 'done' };
const seen: Array<{ code: number; reason: string }> = [];
emitter.on('close', (value) => {
seen.push(value);
});
emitter.emit('close', payload);
expect(seen).toEqual([{ code: 1000, reason: 'done' }]);
expect(seen[0]).toBe(payload);
});
it('forwards an undefined payload for events typed as undefined', () => {
const emitter = new Emitter<TestEvents>();
const seen: Array<undefined> = [];
emitter.on('open', (payload) => {
seen.push(payload);
});
emitter.emit('open', undefined);
expect(seen).toEqual([undefined]);
});
it('stores a listener only once when the same function is registered twice', () => {
const emitter = new Emitter<TestEvents>();
const seen: string[] = [];
const listener = (payload: string) => {
seen.push(payload);
};
emitter.on('data', listener);
emitter.on('data', listener);
emitter.emit('data', 'once-only');
expect(seen).toEqual(['once-only']);
});
it('returns an unsubscribe function from on that stops later delivery', () => {
const emitter = new Emitter<TestEvents>();
const seen: string[] = [];
const off = emitter.on('data', (payload) => {
seen.push(payload);
});
off();
emitter.emit('data', 'nope');
expect(seen).toEqual([]);
});
it('does not throw when emit is called with no listeners for that event', () => {
const emitter = new Emitter<TestEvents>();
expect(() => emitter.emit('data', 'orphan')).not.toThrow();
});
it('does not deliver after every listener has been unsubscribed, even if the event key remains', () => {
const emitter = new Emitter<TestEvents>();
const seen: string[] = [];
const off = emitter.on('data', (payload) => {
seen.push(payload);
});
off();
emitter.emit('data', 'ghost');
expect(seen).toEqual([]);
});
it('fires a once listener exactly once', () => {
const emitter = new Emitter<TestEvents>();
const seen: string[] = [];
emitter.once('data', (payload) => {
seen.push(payload);
});
emitter.emit('data', 'first');
emitter.emit('data', 'second');
expect(seen).toEqual(['first']);
});
it('lets once and on listeners share an event without once firing again', () => {
const emitter = new Emitter<TestEvents>();
const seen: string[] = [];
emitter.on('data', (payload) => {
seen.push(`on:${payload}`);
});
emitter.once('data', (payload) => {
seen.push(`once:${payload}`);
});
emitter.emit('data', 'a');
emitter.emit('data', 'b');
expect(seen).toEqual(['on:a', 'once:a', 'on:b']);
});
it('does not call a once listener that was unsubscribed before emit', () => {
const emitter = new Emitter<TestEvents>();
const seen: string[] = [];
const off = emitter.once('data', (payload) => {
seen.push(payload);
});
off();
emitter.emit('data', 'nope');
expect(seen).toEqual([]);
});
it('allows calling the once unsubscribe after it has already fired', () => {
const emitter = new Emitter<TestEvents>();
const seen: string[] = [];
const off = emitter.once('data', (payload) => {
seen.push(payload);
});
emitter.emit('data', 'first');
off();
emitter.emit('data', 'second');
expect(seen).toEqual(['first']);
});
it('does not re-enter a once listener that emits the same event again', () => {
const emitter = new Emitter<TestEvents>();
const seen: string[] = [];
emitter.once('data', (payload) => {
seen.push(payload);
emitter.emit('data', 'again');
});
emitter.emit('data', 'first');
expect(seen).toEqual(['first']);
});
it('does not cancel a once subscription when off is given the original listener', () => {
const emitter = new Emitter<TestEvents>();
const seen: string[] = [];
const listener = (payload: string) => {
seen.push(payload);
};
emitter.once('data', listener);
emitter.off('data', listener);
emitter.emit('data', 'still');
expect(seen).toEqual(['still']);
});
it('removes only the given listener when off is called', () => {
const emitter = new Emitter<TestEvents>();
const seen: string[] = [];
const keep = (payload: string) => {
seen.push(`keep:${payload}`);
};
const drop = (payload: string) => {
seen.push(`drop:${payload}`);
};
emitter.on('data', keep);
emitter.on('data', drop);
emitter.off('data', drop);
emitter.emit('data', 'x');
expect(seen).toEqual(['keep:x']);
});
it('does not throw when off is called for an event that was never registered', () => {
const emitter = new Emitter<TestEvents>();
expect(() => {
emitter.off('data', () => {});
}).not.toThrow();
});
it('does not throw when off is called with a listener that was never added', () => {
const emitter = new Emitter<TestEvents>();
emitter.on('data', (payload) => {
void payload;
});
expect(() => {
emitter.off('data', () => {});
}).not.toThrow();
});
it('still delivers to a listener removed by an earlier listener during the same emit', () => {
const emitter = new Emitter<TestEvents>();
const seen: string[] = [];
const second = (payload: string) => {
seen.push(`second:${payload}`);
};
emitter.on('data', (payload) => {
seen.push(`first:${payload}`);
emitter.off('data', second);
});
emitter.on('data', second);
emitter.emit('data', 'keep');
expect(seen).toEqual(['first:keep', 'second:keep']);
});
it('does not deliver to a listener added during the same emit', () => {
const emitter = new Emitter<TestEvents>();
const seen: string[] = [];
const late = (payload: string) => {
seen.push(`late:${payload}`);
};
emitter.on('data', (payload) => {
seen.push(`early:${payload}`);
emitter.on('data', late);
});
emitter.emit('data', 'now');
emitter.emit('data', 'next');
expect(seen).toEqual(['early:now', 'early:next', 'late:next']);
});
it('stops all listeners after clear', () => {
const emitter = new Emitter<TestEvents>();
const seen: Array<string | number> = [];
emitter.on('data', (payload) => {
seen.push(payload);
});
emitter.on('count', (payload) => {
seen.push(payload);
});
emitter.clear();
emitter.emit('data', 'x');
emitter.emit('count', 1);
expect(seen).toEqual([]);
});
it('accepts new listeners after clear', () => {
const emitter = new Emitter<TestEvents>();
const seen: string[] = [];
emitter.on('data', (payload) => {
seen.push(`old:${payload}`);
});
emitter.clear();
emitter.on('data', (payload) => {
seen.push(`new:${payload}`);
});
emitter.emit('data', 'after');
expect(seen).toEqual(['new:after']);
});
it('does not throw when off is called after clear', () => {
const emitter = new Emitter<TestEvents>();
const listener = (payload: string) => {
void payload;
};
emitter.on('data', listener);
emitter.clear();
expect(() => {
emitter.off('data', listener);
}).not.toThrow();
});
});
+387
View File
@@ -0,0 +1,387 @@
import { afterEach, beforeEach, describe, expect, expectTypeOf, it, vi } from 'vitest';
import {
SnowLumaAbortError,
SnowLumaConnectionError,
SnowLumaTimeoutError,
SnowLumaWebSocketClient,
isGroupMessageEvent,
type OneBotGroupMessageEvent,
type OneBotMessageEvent,
type OneBotPrivateMessageEvent,
type SnowLumaEvent,
} from '../src';
class EventSocket {
static instances: EventSocket[] = [];
readyState = 0;
readonly listeners = new Map<string, Set<(...args: unknown[]) => void>>();
constructor(readonly url: string) {
EventSocket.instances.push(this);
}
addEventListener(name: string, listener: (...args: unknown[]) => void): void {
const listeners = this.listeners.get(name) ?? new Set();
listeners.add(listener);
this.listeners.set(name, listeners);
}
removeEventListener(name: string, listener: (...args: unknown[]) => void): void {
this.listeners.get(name)?.delete(listener);
}
send(): void {}
close(): void {
this.readyState = 3;
this.emit('close', { code: 1000, reason: 'closed' });
}
open(): void {
this.readyState = 1;
this.emit('open');
}
message(event: SnowLumaEvent): void {
this.emit('message', { data: JSON.stringify(event) });
}
emit(name: string, ...args: unknown[]): void {
for (const listener of [...(this.listeners.get(name) ?? [])]) {
listener(...args);
}
}
}
const privateEvent: OneBotPrivateMessageEvent = {
time: 1,
self_id: 10000,
post_type: 'message',
message_type: 'private',
sub_type: 'friend',
message_id: -7,
user_id: 10001,
message: [{ type: 'text', data: { text: 'hello' } }],
raw_message: 'hello',
font: 0,
sender: { user_id: 10001, nickname: 'tester' },
};
const groupEvent: OneBotGroupMessageEvent = {
...privateEvent,
message_type: 'group',
sub_type: 'normal',
group_id: 20000,
};
describe('WebSocket event waits', () => {
const clients: SnowLumaWebSocketClient[] = [];
beforeEach(() => {
vi.useFakeTimers();
EventSocket.instances = [];
});
afterEach(() => {
for (const client of clients.splice(0)) {
client.close();
}
vi.useRealTimers();
});
function createClient(reconnect = false): SnowLumaWebSocketClient {
const client = new SnowLumaWebSocketClient({
webSocket: EventSocket,
requestTimeoutMs: 1000,
reconnect: reconnect ? { minDelayMs: 10 } : false,
});
clients.push(client);
return client;
}
async function connect(client: SnowLumaWebSocketClient): Promise<EventSocket> {
const opening = client.connect();
const socket = EventSocket.instances.at(-1)!;
socket.open();
await opening;
return socket;
}
it('registers immediately before connect and observes the first matching event', async () => {
const client = createClient();
const predicate = vi.fn((event: SnowLumaEvent) => event.post_type === 'message');
const waiting = client.waitForEvent(predicate);
expect(EventSocket.instances).toHaveLength(0);
const socket = await connect(client);
socket.message(privateEvent);
await expect(waiting).resolves.toEqual(privateEvent);
socket.message(groupEvent);
await vi.advanceTimersByTimeAsync(0);
expect(predicate).toHaveBeenCalledOnce();
expect(vi.getTimerCount()).toBe(0);
});
it('filters messages before invoking a predicate and accepts self-sent messages', async () => {
const client = createClient();
const socket = await connect(client);
const predicate = vi.fn((event: OneBotMessageEvent) => event.message_type === 'group');
const waiting = client.waitForMessage(predicate);
socket.message({
time: 1,
self_id: 10000,
post_type: 'notice',
notice_type: 'group_upload',
});
socket.message(privateEvent);
const sent: OneBotGroupMessageEvent = { ...groupEvent, post_type: 'message_sent' };
socket.message(sent);
await expect(waiting).resolves.toEqual(sent);
expect(predicate).toHaveBeenCalledTimes(2);
});
it('supports type guards, boolean predicates, omitted predicates and options-only calls', async () => {
const client = createClient();
const socket = await connect(client);
const narrowedEvent = client.waitForEvent(isGroupMessageEvent);
const narrowedMessage = client.waitForMessage(isGroupMessageEvent);
const booleanEvent = client.waitForEvent((event): boolean => event.post_type === 'message');
const booleanMessage = client.waitForMessage((event): boolean => event.user_id === 10001);
const anyEvent = client.waitForEvent({ timeoutMs: 100 });
const anyMessage = client.waitForMessage({ timeoutMs: 100 });
const defaultMessage = client.waitForMessage();
const explicitUndefined = client.waitForEvent(undefined, { timeoutMs: 100 });
expectTypeOf(narrowedEvent).toEqualTypeOf<Promise<OneBotGroupMessageEvent>>();
expectTypeOf(narrowedMessage).toEqualTypeOf<Promise<OneBotGroupMessageEvent>>();
expectTypeOf(booleanEvent).toEqualTypeOf<Promise<SnowLumaEvent>>();
expectTypeOf(booleanMessage).toEqualTypeOf<Promise<OneBotMessageEvent>>();
socket.message(groupEvent);
for (const wait of [
narrowedEvent,
narrowedMessage,
booleanEvent,
booleanMessage,
anyEvent,
anyMessage,
defaultMessage,
explicitUndefined,
]) {
await expect(wait).resolves.toEqual(groupEvent);
}
expect(vi.getTimerCount()).toBe(0);
});
it.each([
'success',
'timeout',
'abort',
'predicate error',
'remote close',
'client close',
] as const)(
'cleans event subscriptions, timers and abort listeners after %s',
async (outcome) => {
const client = createClient();
const socket = await connect(client);
const controller = new AbortController();
const removeAbort = vi.spyOn(controller.signal, 'removeEventListener');
const subscribe = client.onEvent.bind(client);
const unsubscribe = vi.fn();
vi.spyOn(client, 'onEvent').mockImplementation((listener) => {
const off = subscribe(listener);
return () => {
unsubscribe();
off();
};
});
const cause = new Error('predicate failed');
const predicate = vi.fn(() => {
if (outcome === 'predicate error') {
throw cause;
}
return true;
});
const waiting = client.waitForEvent(predicate, { signal: controller.signal });
const settled = waiting.then(
(event) => event,
(error: unknown) => error,
);
switch (outcome) {
case 'success':
case 'predicate error':
socket.message(privateEvent);
break;
case 'timeout':
await vi.advanceTimersByTimeAsync(1000);
break;
case 'abort':
controller.abort(cause);
break;
case 'remote close':
socket.close();
break;
case 'client close':
client.close();
break;
}
const result = await settled;
if (outcome === 'success') {
expect(result).toEqual(privateEvent);
} else if (outcome === 'predicate error') {
expect(result).toBe(cause);
} else if (outcome === 'abort') {
expect(result).toBeInstanceOf(SnowLumaAbortError);
expect(result).toHaveProperty('cause', cause);
} else if (outcome === 'timeout') {
expect(result).toBeInstanceOf(SnowLumaTimeoutError);
expect(result).toHaveProperty('timeoutMs', 1000);
} else {
expect(result).toBeInstanceOf(SnowLumaConnectionError);
}
expect(unsubscribe).toHaveBeenCalledOnce();
expect(removeAbort).toHaveBeenCalledWith('abort', expect.any(Function));
expect(vi.getTimerCount()).toBe(0);
socket.message(groupEvent);
await vi.advanceTimersByTimeAsync(0);
expect(predicate).toHaveBeenCalledTimes(
['success', 'predicate error'].includes(outcome) ? 1 : 0,
);
},
);
it('rejects all waits immediately when closed before any connection is created', async () => {
const client = createClient();
const first = expect(client.waitForEvent()).rejects.toBeInstanceOf(SnowLumaConnectionError);
const second = expect(client.waitForMessage({ timeoutMs: 0 })).rejects.toBeInstanceOf(
SnowLumaConnectionError,
);
client.close();
await Promise.all([first, second]);
expect(EventSocket.instances).toHaveLength(0);
expect(vi.getTimerCount()).toBe(0);
});
it('rejects on explicit close even if the socket never emits its close event', async () => {
const client = createClient();
const socket = await connect(client);
vi.spyOn(socket, 'close').mockImplementation(() => {
socket.readyState = 2;
});
const waiting = expect(client.waitForEvent()).rejects.toBeInstanceOf(
SnowLumaConnectionError,
);
client.close();
await waiting;
expect(vi.getTimerCount()).toBe(0);
});
it('rejects a pre-aborted signal without adding event listeners or timers', async () => {
const client = createClient();
const controller = new AbortController();
const cause = new Error('cancelled');
controller.abort(cause);
const subscribe = vi.spyOn(client, 'onEvent');
await expect(client.waitForMessage({ signal: controller.signal })).rejects.toMatchObject({
name: 'SnowLumaAbortError',
cause,
});
expect(subscribe).not.toHaveBeenCalled();
expect(vi.getTimerCount()).toBe(0);
});
it('does not carry a wait across an unexpected disconnect and automatic reconnect', async () => {
const client = createClient(true);
const socket = await connect(client);
const predicate = vi.fn(() => true);
const waiting = expect(client.waitForEvent(predicate)).rejects.toBeInstanceOf(
SnowLumaConnectionError,
);
socket.close();
await waiting;
await vi.advanceTimersByTimeAsync(10);
const replacement = EventSocket.instances.at(-1)!;
replacement.open();
replacement.message(privateEvent);
await vi.advanceTimersByTimeAsync(0);
expect(predicate).not.toHaveBeenCalled();
const newWait = client.waitForMessage();
replacement.message(groupEvent);
await expect(newWait).resolves.toEqual(groupEvent);
});
it('observes middleware ordering and ignores stopped events', async () => {
const client = createClient();
const socket = await connect(client);
const predicate = vi.fn((event: SnowLumaEvent) => event.raw_message === 'processed');
client.use(async (event, context, next) => {
if (event.message_id === -7) {
context.stopPropagation();
return;
}
event.raw_message = 'processed';
await next();
});
const waiting = client.waitForEvent(predicate);
socket.message(privateEvent);
await vi.advanceTimersByTimeAsync(0);
expect(predicate).not.toHaveBeenCalled();
socket.message({ ...groupEvent, message_id: -8 });
await expect(waiting).resolves.toMatchObject({ message_id: -8, raw_message: 'processed' });
expect(predicate).toHaveBeenCalledOnce();
});
it('allows a zero timeout and still supports cancellation', async () => {
const client = createClient();
const controller = new AbortController();
const waiting = expect(
client.waitForEvent({ timeoutMs: 0, signal: controller.signal }),
).rejects.toBeInstanceOf(SnowLumaAbortError);
expect(vi.getTimerCount()).toBe(0);
await vi.advanceTimersByTimeAsync(100_000);
controller.abort();
await waiting;
});
it('keeps cancellation authoritative when a predicate cancels its own wait', async () => {
const client = createClient();
const socket = await connect(client);
const controller = new AbortController();
const removeAbort = vi.spyOn(controller.signal, 'removeEventListener');
const waiting = expect(
client.waitForMessage(
() => {
controller.abort('cancel while checking');
return true;
},
{ signal: controller.signal },
),
).rejects.toMatchObject({
name: 'SnowLumaAbortError',
cause: 'cancel while checking',
});
socket.message(privateEvent);
await waiting;
expect(removeAbort).toHaveBeenCalledOnce();
expect(vi.getTimerCount()).toBe(0);
});
it.each([-1, Number.NaN, Number.POSITIVE_INFINITY, 2_147_483_648])(
'rejects invalid timeout %s before subscribing',
async (timeoutMs) => {
const client = createClient();
const subscribe = vi.spyOn(client, 'onEvent');
await expect(client.waitForEvent({ timeoutMs })).rejects.toBeInstanceOf(RangeError);
expect(subscribe).not.toHaveBeenCalled();
expect(vi.getTimerCount()).toBe(0);
},
);
});
+88
View File
@@ -0,0 +1,88 @@
import { describe, expect, it, vi } from 'vitest';
import {
createEventContext,
isGroupMessageEvent,
isPrivateMessageEvent,
matchCommand,
text,
type OneBotPrivateMessageEvent,
type OneBotRequestEvent,
type SnowLumaApiClient,
} from '../src';
const PRIVATE_EVENT: OneBotPrivateMessageEvent = {
time: 1,
self_id: 10000,
post_type: 'message',
message_type: 'private',
sub_type: 'friend',
message_id: 7,
message_seq: 8,
user_id: 10001,
message: [{ type: 'text', data: { text: '/ping a b' } }],
raw_message: '/ping a b',
font: 0,
sender: { user_id: 10001, nickname: 'tester' },
};
function fakeClient(): SnowLumaApiClient {
return {
sendPrivateMessage: vi.fn().mockResolvedValue({ message_id: 1 }),
sendGroupMessage: vi.fn().mockResolvedValue({ message_id: 2 }),
setFriendAddRequest: vi.fn().mockResolvedValue(null),
setGroupAddRequest: vi.fn().mockResolvedValue(null),
raw: vi.fn().mockResolvedValue(null),
} as unknown as SnowLumaApiClient;
}
describe('event helpers', () => {
it('narrows message event types', () => {
expect(isPrivateMessageEvent(PRIVATE_EVENT)).toBe(true);
expect(isGroupMessageEvent(PRIVATE_EVENT)).toBe(false);
});
it('replies to private messages through context', async () => {
const client = fakeClient();
const context = createEventContext(PRIVATE_EVENT, client);
await context.reply(text('pong'));
expect(client.sendPrivateMessage).toHaveBeenCalledWith(10001, text('pong'), undefined);
});
it('approves and rejects request events', async () => {
const client = fakeClient();
const request: OneBotRequestEvent = {
time: 1,
self_id: 10000,
post_type: 'request',
request_type: 'group',
sub_type: 'invite',
flag: 'flag-1',
};
const context = createEventContext(request, client);
await context.approve();
await context.reject('nope');
expect(client.setGroupAddRequest).toHaveBeenNthCalledWith(1, 'flag-1', {
subType: 'invite',
approve: true,
});
expect(client.setGroupAddRequest).toHaveBeenNthCalledWith(2, 'flag-1', {
subType: 'invite',
approve: false,
reason: 'nope',
});
});
it('matches commands with prefixes', () => {
expect(matchCommand(PRIVATE_EVENT, 'ping')).toMatchObject({
command: 'ping',
args: ['a', 'b'],
rest: 'a b',
prefix: '/',
});
expect(matchCommand(PRIVATE_EVENT, 'pong')).toBeNull();
});
});
+288
View File
@@ -0,0 +1,288 @@
import { describe, expect, it, vi } from 'vitest';
import {
SnowLumaAbortError,
SnowLumaApiError,
SnowLumaHttpClient,
SnowLumaParseError,
} from '../src/index.js';
import type { JsonObject } from '../src/index.js';
const wrap = (data: unknown) => ({ status: 'ok', retcode: 0, stream: 'stream-action', data });
const wire = (data: unknown) => JSON.stringify(wrap(data)) + '\r\n\r\n';
const info = {
type: 'stream',
data_type: 'file_info',
file_name: 'test',
file_size: 2,
chunk_size: 2,
};
const chunk = {
type: 'stream',
data_type: 'file_chunk',
index: 0,
data: 'YWI=',
size: 2,
base64_size: 4,
progress: 100,
};
const complete = {
type: 'response',
data_type: 'file_complete',
file_name: 'test',
total_chunks: 1,
total_bytes: 2,
message: 'done',
};
async function download(frames: unknown[], onInfo?: (value: unknown) => void) {
const client = new SnowLumaHttpClient({
fetch: async () => new Response(frames.map(wire).join('')),
});
const stream = client.downloadFileStream({ file: 'test' }, { onInfo });
const chunks: Uint8Array[] = [];
while (true) {
const result = await stream.next();
if (result.done) {
return { chunks, complete: result.value };
}
chunks.push(result.value);
}
}
describe('file download helper', () => {
it('yields bytes, exposes metadata and returns verified completion', async () => {
const onInfo = vi.fn();
expect(await download([info, chunk, complete], onInfo)).toEqual({
chunks: [new Uint8Array([97, 98])],
complete,
});
expect(onInfo).toHaveBeenCalledExactlyOnceWith(info);
});
it('accepts an unknown initial size and verifies the final totals', async () => {
expect(
(await download([{ ...info, file_size: 0 }, chunk, complete])).complete.total_bytes,
).toBe(2);
});
it('supports empty files', async () => {
expect(
(
await download([
{ ...info, file_size: 0 },
{ ...complete, total_chunks: 0, total_bytes: 0 },
])
).chunks,
).toEqual([]);
});
it.each([
['missing info', [chunk, complete]],
['duplicate info', [info, info, chunk, complete]],
['missing chunk', [info, complete]],
['out-of-order index', [info, { ...chunk, index: 1 }, complete]],
['invalid base64', [info, { ...chunk, data: '????' }, complete]],
['non-canonical base64', [info, { ...chunk, data: 'YWL=' }, complete]],
['wrong chunk size', [info, { ...chunk, size: 3 }, complete]],
['wrong base64 size', [info, { ...chunk, base64_size: 5 }, complete]],
['wrong final size', [info, chunk, { ...complete, total_bytes: 3 }]],
['wrong final count', [info, chunk, { ...complete, total_chunks: 2 }]],
['wrong info size', [{ ...info, file_size: 3 }, chunk, complete]],
['wrong final name', [info, chunk, { ...complete, file_name: 'other' }]],
['unexpected frame', [info, { type: 'stream', data_type: 'other' }, complete]],
])('rejects %s', async (_name, frames) => {
await expect(download(frames as unknown[])).rejects.toBeInstanceOf(SnowLumaParseError);
});
});
function uploader(transform?: (data: JsonObject, params: JsonObject) => JsonObject) {
const calls: JsonObject[] = [];
const fetch = vi.fn(async (_input: string | URL | Request, init?: RequestInit) => {
const { params } = JSON.parse(String(init?.body)) as { params: JsonObject };
calls.push(params);
const first = calls[0];
const result: JsonObject = params.is_complete
? {
type: 'response',
status: 'file_complete',
stream_id: first.stream_id,
received_chunks: first.total_chunks,
total_chunks: first.total_chunks,
file_size: first.file_size,
file_path: '/remote/file',
file_name: 'test',
sha256: 'a'.repeat(64),
}
: {
type: 'stream',
status: 'chunk_received',
stream_id: params.stream_id,
received_chunks: Number(params.chunk_index) + 1,
total_chunks: params.total_chunks,
};
return new Response(wire(transform ? transform(result, params) : result));
});
return { client: new SnowLumaHttpClient({ fetch }), calls, fetch };
}
describe('file upload helper', () => {
it('cancels a stalled readable source before any request is sent', async () => {
const { client, fetch } = uploader();
const cancel = vi.fn();
const source = new ReadableStream<Uint8Array>({ cancel });
const controller = new AbortController();
const upload = client.uploadFileStream(source, { fileSize: 1, signal: controller.signal });
await vi.waitFor(() => expect(source.locked).toBe(true));
controller.abort();
await expect(upload).rejects.toBeInstanceOf(SnowLumaAbortError);
expect(cancel).toHaveBeenCalledOnce();
expect(source.locked).toBe(false);
expect(fetch).not.toHaveBeenCalled();
});
it('does not send later chunks or finalize after cancellation from a progress callback', async () => {
const { client, calls } = uploader();
const controller = new AbortController();
await expect(
client.uploadFileStream(new Uint8Array([1, 2]), {
chunkSize: 1,
signal: controller.signal,
onProgress: () => controller.abort(),
}),
).rejects.toBeInstanceOf(SnowLumaAbortError);
expect(calls).toHaveLength(1);
});
it('re-chunks async input, awaits progress and separately finalizes after all ACKs', async () => {
const { client, calls } = uploader();
const progress = vi.fn();
let readSecond = false;
const source = (async function* () {
yield new Uint8Array([1, 2, 3, 4]);
expect(progress).toHaveBeenCalledTimes(2);
readSecond = true;
yield new Uint8Array([5]);
})();
const result = await client.uploadFileStream(source, {
fileSize: 5,
chunkSize: 2,
filename: 'input',
streamId: 'test',
onProgress: progress,
});
expect(readSecond).toBe(true);
expect(calls.map((params) => params.chunk_data)).toEqual([
'AQI=',
'AwQ=',
'BQ==',
undefined,
]);
expect(calls[0]).toMatchObject({ total_chunks: 3, file_size: 5, filename: 'input' });
expect(calls[3]).toEqual({ stream_id: 'test', is_complete: true });
expect(progress).toHaveBeenLastCalledWith({
streamId: 'test',
uploadedChunks: 3,
totalChunks: 3,
uploadedBytes: 5,
fileSize: 5,
});
expect(result.file_path).toBe('/remote/file');
});
it.each([new Uint8Array(), new Blob([]), new Blob(['ab'])])(
'infers size for byte and Blob sources: %s',
async (source) => {
const { client, calls } = uploader();
const result = await client.uploadFileStream(source);
expect(result.file_size).toBe(source instanceof Blob ? source.size : source.length);
expect(calls).toHaveLength(2);
expect(calls[0].total_chunks).toBe(1);
},
);
it.each([0, 4])(
'does not finalize a streaming source with mismatched size %i',
async (fileSize) => {
const { client, calls } = uploader();
let closed = false;
const source = (async function* () {
try {
yield new Uint8Array([1, 2]);
} finally {
closed = true;
}
})();
await expect(
client.uploadFileStream(source, { fileSize, chunkSize: 1 }),
).rejects.toThrow('fileSize');
expect(closed).toBe(true);
expect(calls.some((call) => call.is_complete)).toBe(false);
},
);
it('requires a declared size and validates options before making requests', async () => {
const { client, fetch } = uploader();
const source = (async function* () {
yield new Uint8Array([1]);
})();
await expect(client.uploadFileStream(source)).rejects.toThrow('fileSize');
await expect(client.uploadFileStream(new Uint8Array([1]), { fileSize: 2 })).rejects.toThrow(
'fileSize',
);
await expect(
client.uploadFileStream(new Uint8Array([1]), { chunkSize: 0 }),
).rejects.toThrow('chunkSize');
await expect(
client.uploadFileStream(new Uint8Array([1]), { streamId: '../bad' }),
).rejects.toThrow('streamId');
expect(fetch).not.toHaveBeenCalled();
});
it('rejects incorrect ACKs and cancels a readable source on failure', async () => {
const { client, calls } = uploader((data) => ({ ...data, received_chunks: 99 }));
const cancel = vi.fn();
const source = new ReadableStream<Uint8Array>({
start(c) {
c.enqueue(new Uint8Array([1, 2]));
},
cancel,
});
await expect(client.uploadFileStream(source, { fileSize: 2 })).rejects.toBeInstanceOf(
SnowLumaParseError,
);
expect(calls).toHaveLength(1);
expect(cancel).toHaveBeenCalledOnce();
expect(source.locked).toBe(false);
});
it('stops after a failed chunk and preserves the server error', async () => {
const fetch = vi.fn(
async () =>
new Response(
JSON.stringify({
status: 'failed',
retcode: 1500,
data: { type: 'error' },
wording: 'disk full',
}),
),
);
const client = new SnowLumaHttpClient({ fetch });
await expect(
client.uploadFileStream(new Uint8Array(10), { chunkSize: 2 }),
).rejects.toBeInstanceOf(SnowLumaApiError);
expect(fetch).toHaveBeenCalledOnce();
});
it('rejects inconsistent final metadata', async () => {
const { client } = uploader((data, params) =>
params.is_complete ? { ...data, file_size: 99 } : data,
);
await expect(client.uploadFileStream(new Uint8Array([1]))).rejects.toThrow(
'completion metadata',
);
});
});
+579
View File
@@ -0,0 +1,579 @@
import type { ActionParams, ActionResult } from '@snowluma/sdk';
const album = { group_id: '10001', album_id: 'album' };
const mediaLike = { ...album, batch_id: '9007199254740993', lloc: 'photo' };
const mediaUpload = { ...album, album_name: 'Album', file: 'file:///fixture' };
const download = { file: 'https://example.com/file', file_id: 'cached', chunk_size: '65536' };
const fileset = { fileset_id: 'fileset' };
const flashFile = { ...fileset, file_name: 'file.txt', file_index: '0' };
const peer = { group_id: '10001', user_id: '10002', phone_number: '' };
const qzoneLike = { tid: 'post', target_uin: '10002', abstime: '1700000000' };
const qzonePublicFeed = {
cell_common: {
domain_type: 3,
union_id: { uf_key: '9007199254740993', unknown_identity: 'preserved' },
},
cell_feed_transfer: { opaque: 'fixture' },
unknown_cell: { decimal: '18446744073709551615' },
};
const qzonePublicComment = {
id: '9007199254740993',
user: { uid: 'public-reader' },
like: { id: 'comment-like-key' },
};
const qzonePublicReply = {
id: '18446744073709551615',
user: { uid: 'public-author' },
like: { id: 'reply-like-key' },
};
// The same examples are compiled against the published declarations and parsed
// by the real server action definitions. They never invoke QQ or perform I/O.
export const actionParams = {
get_friend_list: { with_profile: true },
get_friends_with_category: { with_profile: false },
get_group_admin_permissions: { group_id: '10001', user_id: '10002' },
set_group_admin_permissions: {
group_id: '10001',
user_id: '10002',
recall_member_message: false,
kick_member: true,
kick_member_per_day: '20',
},
set_group_admin_receive_verify: { group_id: '10001', user_id: '10002', enable: 'on' },
_get_friend_dress: { user_id: '10002' },
add_custom_face: { file: 'file:///fixture.png' },
cancel_group_album_media_like: mediaLike,
clean_stream_temp_file: {},
comment_qzone: {
tid: 'post',
content: 'Comment',
target_uin: '10002',
images: [],
comment_id: '0',
comment_uin: '10003',
reply_uin: '10004',
reply_name: 'Reader',
},
comment_qzone_public_feed: {
feed: qzonePublicFeed,
content: [{ type: 0, content: 'Comment', upstream_option: true }],
},
create_flash_task: {
files: ['file:///one', { file: 'file:///two', name: 'two.txt' }],
name: 'Files',
thumb_path: '',
},
del_group_album_media: { ...album, lloc: 'photo' },
delete_custom_face: { emoji_id: 'face' },
delete_flash_file: fileset,
delete_qzone_msg: { tid: 'post' },
delete_qzone_public_comment: { feed: qzonePublicFeed, comment: qzonePublicComment },
delete_qzone_public_reply: {
feed: qzonePublicFeed,
comment: qzonePublicComment,
reply: qzonePublicReply,
},
do_group_album_comment: { ...album, lloc: 'photo', content: 'Comment' },
download_file_image_stream: download,
download_file_record_stream: download,
download_file_stream: download,
download_fileset: flashFile,
fetch_custom_face_detail: { count: '48' },
fetch_ptt_text: { message_id: -42 },
forward_qzone_public_feed: { feed: qzonePublicFeed, reason: '' },
get_ptt_text: { message_id: '-42' },
get_record_text: { message_id: -42 },
get_doubt_friends_add_request: { count: '50' },
get_fileset_id: { share_code: 'share' },
get_fileset_info: fileset,
get_flash_file_list: fileset,
get_flash_file_url: flashFile,
get_group_album_list: { group_id: '10001' },
get_group_album_media_list: { ...album, attach_info: '' },
get_group_signed_list: { group_id: '10001' },
get_msg_emoji_likes: { message_id: '-42' },
get_qun_album_list: { group_id: '10001', attach_info: '' },
get_qzone_feeds: { page_num: '1', count: '10', cursor: 'cursor-from-previous-response' },
get_qzone_msg_list: { target_uin: '10002', pos: '0', num: '20' },
get_qzone_profile: { target_uin: '10002' },
get_qzone_public_capabilities: {},
get_qzone_home_feeds: { target_uin: '10001', cursor: 'home-feed-cursor' },
get_qzone_feed_detail: { tid: '1127000000f1536501000000' },
get_qzone_share_ark: { feed: qzonePublicFeed },
get_qzone_public_comments: { feed: qzonePublicFeed, cursor: 'comments-from-detail' },
get_qzone_public_feed_detail: { feed: qzonePublicFeed },
get_qzone_public_feeds: { cursor: 'public-feed-cursor' },
get_qzone_public_replies: {
feed: qzonePublicFeed,
comment: qzonePublicComment,
cursor: '',
page_size: '20',
},
get_rkey_server: {},
get_share_link: fileset,
like_qzone: qzoneLike,
list_filesets: {},
modify_custom_face: { emoji_id: 'face', desc: 'Description' },
move_custom_face_to_front: { emoji_id: 'face' },
rename_flash_file: { ...fileset, new_name: 'new.txt' },
reply_qzone_public_feed: {
feed: qzonePublicFeed,
comment: qzonePublicComment,
content: 'Reply',
reply: qzonePublicReply,
},
request_decrypt_key: { db_path: '/fixture.db' },
send_ark_share: peer,
send_flash_msg: { ...fileset, group_id: '10001', user_id: '10002' },
send_group_ark_share: { group_id: '10001' },
send_qzone_msg: {
content: 'Post',
images: [],
ugc_right: '16',
target_uins: ['10002'],
},
send_tuwen_ark: {
group_id: '10001',
user_id: '10002',
title: 'Title',
desc: 'Description',
summary: 'Summary',
preview_url: '',
jump_url: 'https://example.com/',
},
set_doubt_friends_add_request: { flag: 'u_fixture', approve: 'yes' },
set_group_album_media_like: mediaLike,
set_group_member_invite_policy: { group_id: '10001', policy: 'require_approval' },
set_group_member_permissions: {
group_id: '10001',
allow_member_upload_album: false,
allow_member_temporary_session: 'off',
allow_member_create_group: 1,
},
set_group_new_member_history_visibility: { group_id: '10001', visible: 'on' },
set_group_robot_add_option: {
group_id: '10001',
robot_member_switch: '1',
robot_member_examine: '0',
},
set_qzone_ban: { user_id: '10002', enable: false },
set_qzone_msg_right: { tid: 'post', ugc_right: '128', target_uins: ['10002'] },
set_qzone_profile: { name: 'Space', description: '' },
set_qzone_public_follow: { feed: qzonePublicFeed, enable: 'off' },
set_qzone_public_comment_like: {
feed: qzonePublicFeed,
comment: qzonePublicComment,
reply: qzonePublicReply,
enable: 'on',
},
set_qzone_public_like: { feed: qzonePublicFeed, enable: 0 },
set_qzone_public_upvote: { feed: qzonePublicFeed, enable: true },
share_group_ex: { group_id: '10001' },
share_peer: peer,
test_download_stream: { error: false },
unlike_qzone: qzoneLike,
upload_file_stream: {
stream_id: 'fixture',
chunk_data: 'YQ==',
chunk_index: '0',
total_chunks: '1',
file_size: '1',
expected_sha256: '',
is_complete: 'false',
filename: 'file.txt',
reset: false,
verify_only: 0,
file_retention: '0',
},
upload_image_to_qun_album: mediaUpload,
upload_video_to_qun_album: mediaUpload,
'.handle_quick_operation_async': { context: {}, operation: {} },
} satisfies { [K in CoveredAction]: ActionParams<K> };
// Keep this independent of SnowLumaActionMap: a missing map entry must fail the
// consumer's coverage check rather than silently remove that example.
export type CoveredAction =
| 'get_friend_list'
| 'get_friends_with_category'
| 'get_group_admin_permissions'
| 'set_group_admin_permissions'
| 'set_group_admin_receive_verify'
| '_get_friend_dress'
| 'add_custom_face'
| 'cancel_group_album_media_like'
| 'clean_stream_temp_file'
| 'comment_qzone'
| 'comment_qzone_public_feed'
| 'create_flash_task'
| 'del_group_album_media'
| 'delete_custom_face'
| 'delete_flash_file'
| 'delete_qzone_msg'
| 'delete_qzone_public_comment'
| 'delete_qzone_public_reply'
| 'do_group_album_comment'
| 'download_file_image_stream'
| 'download_file_record_stream'
| 'download_file_stream'
| 'download_fileset'
| 'fetch_custom_face_detail'
| 'fetch_ptt_text'
| 'forward_qzone_public_feed'
| 'get_ptt_text'
| 'get_record_text'
| 'get_doubt_friends_add_request'
| 'get_fileset_id'
| 'get_fileset_info'
| 'get_flash_file_list'
| 'get_flash_file_url'
| 'get_group_album_list'
| 'get_group_album_media_list'
| 'get_group_signed_list'
| 'get_msg_emoji_likes'
| 'get_qun_album_list'
| 'get_qzone_feeds'
| 'get_qzone_msg_list'
| 'get_qzone_profile'
| 'get_qzone_public_capabilities'
| 'get_qzone_home_feeds'
| 'get_qzone_feed_detail'
| 'get_qzone_share_ark'
| 'get_qzone_public_comments'
| 'get_qzone_public_feed_detail'
| 'get_qzone_public_feeds'
| 'get_qzone_public_replies'
| 'get_rkey_server'
| 'get_share_link'
| 'like_qzone'
| 'list_filesets'
| 'modify_custom_face'
| 'move_custom_face_to_front'
| 'rename_flash_file'
| 'reply_qzone_public_feed'
| 'request_decrypt_key'
| 'send_ark_share'
| 'send_flash_msg'
| 'send_group_ark_share'
| 'send_qzone_msg'
| 'send_tuwen_ark'
| 'set_doubt_friends_add_request'
| 'set_group_album_media_like'
| 'set_group_member_invite_policy'
| 'set_group_member_permissions'
| 'set_group_new_member_history_visibility'
| 'set_group_robot_add_option'
| 'set_qzone_ban'
| 'set_qzone_msg_right'
| 'set_qzone_profile'
| 'set_qzone_public_follow'
| 'set_qzone_public_comment_like'
| 'set_qzone_public_like'
| 'set_qzone_public_upvote'
| 'share_group_ex'
| 'share_peer'
| 'test_download_stream'
| 'unlike_qzone'
| 'upload_file_stream'
| 'upload_image_to_qun_album'
| 'upload_video_to_qun_album'
| '.handle_quick_operation_async';
const flashInfo = {
fileset_id: 'fileset',
file_name: 'file.txt',
orig_name: 'original.txt',
size: 42,
share_url: 'https://example.com/share',
file_id: 'file',
download_url: '',
};
export const actionResults = {
get_friend_list: [
{
user_id: 10001,
nickname: 'Friend',
remark: '',
category_id: 0,
long_nick: '',
age: 0,
is_qq_vip: false,
privilege_icons: {
open: [{ type: 1, icon_url: 'normal', deluxe_icon_url: 'deluxe' }],
closed: [],
},
},
],
get_friends_with_category: [
{
categoryId: 0,
categoryName: 'Friends',
categoryMbCount: 1,
buddyList: [{ user_id: 10001, nickname: 'Friend', remark: '', city: 'City' }],
},
],
get_group_admin_permissions: {
group_id: 10001,
user_id: 10002,
recall_member_message: null,
kick_member: true,
kick_member_per_day: 20,
receive_verify: false,
},
set_group_admin_permissions: null,
set_group_admin_receive_verify: null,
_get_friend_dress: {
target_uin: '10002',
is_svip: true,
avatar_url: '',
items: [
{
app_id: 15,
kind: 'card',
item_id: 1,
name: 'Card',
preview_url: '',
video_url: '',
price: 0,
},
],
},
add_custom_face: { emoji_id: 'face' },
cancel_group_album_media_like: {},
clean_stream_temp_file: { message: 'success', removed: 1, freed_bytes: 42, skipped_active: 1 },
comment_qzone: { comment_id: 'comment' },
comment_qzone_public_feed: { comment: qzonePublicComment },
create_flash_task: { fileset_id: 'fileset', task_id: 'fileset' },
del_group_album_media: { success: true },
delete_custom_face: null,
delete_flash_file: null,
delete_qzone_msg: null,
delete_qzone_public_comment: {},
delete_qzone_public_reply: {},
do_group_album_comment: {
id: 'comment',
user: { uin: '10002' },
content: [{ type: 0, content: 'Comment' }],
time: '1700000000',
clientKey: 'fixture',
},
download_file_image_stream: {
type: 'stream',
data_type: 'file_info',
file_name: 'image',
file_size: 1,
chunk_size: 1,
},
download_file_record_stream: {
type: 'stream',
data_type: 'file_chunk',
index: 0,
data: 'YQ==',
size: 1,
progress: 100,
base64_size: 4,
},
download_file_stream: {
type: 'response',
data_type: 'file_complete',
file_name: 'file',
total_chunks: 1,
total_bytes: 1,
message: 'Download completed',
},
download_fileset: { url: '', file_name: 'file.txt', file_size: 42 },
fetch_custom_face_detail: [{ emoji_id: 'face', resId: 'face', url: '', md5: '', desc: '' }],
fetch_ptt_text: { text: 'Speech' },
forward_qzone_public_feed: {},
get_ptt_text: { text: 'Speech' },
get_record_text: { text: 'Speech' },
get_doubt_friends_add_request: [
{
uid: 'u_fixture',
user_id: 10002,
nick: 'User',
source: '',
reason: '',
msg: '',
group_code: '',
reqTime: 0,
},
],
get_fileset_id: { fileset_id: 'fileset' },
get_fileset_info: { fileset_id: 'fileset', file_list: [flashInfo] },
get_flash_file_list: [flashInfo],
get_flash_file_url: { url: '' },
get_group_album_list: [
{
id: 'album',
name: 'Album',
picNum: 1,
createTime: 1,
desc: '',
owner: '10002',
createuin: '10002',
createnickname: 'User',
last_upload_time: 1,
cover: null,
},
],
get_group_album_media_list: {
mediaList: [
{
type: 2,
video: { id: 'video', videoTime: '1000' },
batchId: '9007199254740993',
uploadTime: '1',
},
],
nextAttachInfo: '',
},
get_group_signed_list: [{ user_id: 10002, nick: 'User', time: 1, rank: 1 }],
get_msg_emoji_likes: [
{
emoji_id: '1',
emoji_type: 1,
count: 1,
last_reaction_time: 1,
users: [{ user_id: 10002 }],
},
],
get_qun_album_list: {
album_list: [
{
album_id: 'album',
owner: '10002',
name: 'Album',
desc: '',
create_time: '1',
modify_time: '1',
last_upload_time: '1',
upload_number: '9007199254740993',
cover: {
type: 1,
image: {
name: '',
sloc: '',
lloc: '',
photoUrls: [],
defaultUrl: null,
isGif: false,
hasRaw: false,
},
},
top_flag: '0',
busi_type: 0,
status: 0,
allow_share: true,
is_subscribe: false,
bitmap: '',
is_share_album: false,
qz_album_type: 0,
cover_type: 0,
default_desc: '',
sort_type: 0,
},
],
attach_info: '',
has_more: false,
},
get_qzone_feeds: {
feeds: [{ uin: 10002, nickname: 'User', time: 1, appid: 311, key: 'post', html: '' }],
has_more: false,
},
get_qzone_msg_list: {
total: 1,
msglist: [
{
tid: 'post',
content: 'Post',
time: 1,
comment_num: 0,
is_private: false,
images: [],
},
],
},
get_qzone_profile: { name: 'Space', description: 'Description' },
get_qzone_public_capabilities: { public_qzone_entrance_switch: true, can_upvote: true },
get_qzone_home_feeds: { feeds: [qzonePublicFeed], has_more: false, next_cursor: null },
get_qzone_feed_detail: {
feed: {
cell_common: { domain_type: 1, union_id: { feed_id: '1127000000f1536501000000' } },
cell_pic: { future_image_field: true },
},
attach_info: 'comments-from-detail',
},
get_qzone_share_ark: {
ark: '{"app":"com.tencent.tuwen.lua"}',
requires_confirmation: false,
notice: null,
},
get_qzone_public_comments: {
cell_comment: { comments: [qzonePublicComment] },
attach_info: 'next-comments',
has_more: true,
},
get_qzone_public_feed_detail: {
feed: { feed: qzonePublicFeed },
attach_info: 'comments-from-detail',
has_more: true,
},
get_qzone_public_feeds: {
feeds: [qzonePublicFeed],
has_more: true,
next_cursor: 'public-feed-cursor',
unknown_page: { preserved: true },
},
get_qzone_public_replies: {
replys: [qzonePublicReply],
reply_attach_info: '',
has_more: false,
},
get_rkey_server: { expired_time: 1, name: 'SnowLuma', private_rkey: 'fixture' },
get_share_link: 'https://example.com/share',
like_qzone: null,
list_filesets: [flashInfo],
modify_custom_face: null,
move_custom_face_to_front: null,
rename_flash_file: null,
reply_qzone_public_feed: { reply: qzonePublicReply },
request_decrypt_key: { db_key: 'fixture' },
send_ark_share: { arkMsg: '{}' },
send_flash_msg: { message_id: 0 },
send_group_ark_share: '{}',
send_qzone_msg: { tid: 'post', time: 1 },
send_tuwen_ark: null,
set_doubt_friends_add_request: null,
set_group_album_media_like: { id: 'like', status: 1 },
set_group_member_invite_policy: null,
set_group_member_permissions: null,
set_group_new_member_history_visibility: null,
set_group_robot_add_option: null,
set_qzone_ban: null,
set_qzone_msg_right: { ugc_right: 128 },
set_qzone_profile: null,
set_qzone_public_follow: { ret_code: 0 },
set_qzone_public_comment_like: { like: { status: 1 } },
set_qzone_public_like: { like: { liked: true } },
set_qzone_public_upvote: { upvote: { upvoted: true } },
share_group_ex: '{}',
share_peer: { arkMsg: '{}' },
test_download_stream: {
type: 'response',
data_type: 'data_complete',
data: 'Stream transmission complete',
},
unlike_qzone: null,
upload_file_stream: {
type: 'stream',
stream_id: 'fixture',
status: 'chunk_received',
received_chunks: 1,
total_chunks: 1,
},
upload_image_to_qun_album: null,
upload_video_to_qun_album: { id: 'video' },
'.handle_quick_operation_async': null,
} satisfies { [K in CoveredAction]: ActionResult<K> };
+324
View File
@@ -0,0 +1,324 @@
import type {
ActionParams,
ActionResult,
DownloadFileStreamComplete,
JsonObject,
JsonValue,
SnowLumaActionMap,
SnowLumaHttpClient,
SnowLumaKnownAction,
SnowLumaWebSocketClient,
StreamApiResponse,
UploadFileStreamComplete,
} from '@snowluma/sdk';
import { actionParams, actionResults, type CoveredAction } from './action-contracts.js';
type AssertNever<T extends never> = T;
export type MissingActionTypes = AssertNever<Exclude<SnowLumaKnownAction, keyof SnowLumaActionMap>>;
export type UnregisteredActionTypes = AssertNever<
Exclude<keyof SnowLumaActionMap, SnowLumaKnownAction>
>;
export type UnspecifiedResults = AssertNever<
{
[K in CoveredAction]: JsonValue extends ActionResult<K> ? K : never;
}[CoveredAction]
>;
type AssertTrue<T extends true> = T;
type Equal<A, B> =
(<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2 ? true : false;
export type PttAliasParams = AssertTrue<
Equal<ActionParams<'get_ptt_text'>, ActionParams<'fetch_ptt_text'>>
>;
export type PttAliasResult = AssertTrue<
Equal<ActionResult<'get_record_text'>, ActionResult<'fetch_ptt_text'>>
>;
export type QuickAlias = AssertTrue<
Equal<
SnowLumaActionMap['.handle_quick_operation_async'],
SnowLumaActionMap['.handle_quick_operation']
>
>;
declare const http: SnowLumaHttpClient;
declare const ws: SnowLumaWebSocketClient;
const groupSystemMessages = await http.getGroupSystemMessages({ groupId: 10001 });
export const invitationTarget: number | undefined = groupSystemMessages[0]?.target_uin;
export const invitationOperator: number | undefined = groupSystemMessages[0]?.operator_uin;
export const invitationIdentityStatus: 'known' | 'unknown' | undefined =
groupSystemMessages[0]?.invitor_status;
const adminPermissions = await http.raw('get_group_admin_permissions', {
group_id: 10001,
user_id: 10002,
});
export const adminRecall: boolean | null = adminPermissions.recall_member_message;
export const adminKickLimit: number | null = adminPermissions.kick_member_per_day;
export const adminReceiveVerify: boolean | null = adminPermissions.receive_verify;
await http.raw('set_group_admin_permissions', {
group_id: 10001,
user_id: 10002,
recall_member_message: false,
});
await http.raw('set_group_admin_permissions', {
group_id: 10001,
user_id: 10002,
kick_member: false,
});
await http.raw('set_group_admin_permissions', {
group_id: 10001,
user_id: 10002,
kick_member: true,
kick_member_per_day: 0,
});
await http.raw('set_group_admin_receive_verify', {
group_id: 10001,
user_id: 10002,
enable: false,
});
// @ts-expect-error a patch must change a permission
http.raw('set_group_admin_permissions', { group_id: 10001, user_id: 10002 });
// @ts-expect-error enabling removal requires an explicit limit
http.raw('set_group_admin_permissions', { group_id: 10001, user_id: 10002, kick_member: true });
http.raw('set_group_admin_permissions', {
group_id: 10001,
user_id: 10002,
kick_member: false,
// @ts-expect-error a limit without enabling removal is invalid
kick_member_per_day: 20,
});
http.raw('set_group_admin_permissions', {
group_id: 10001,
user_id: 10002,
kick_member: true,
// @ts-expect-error only supported limits may be written
kick_member_per_day: 1,
});
// @ts-expect-error receive-verification enable is required
http.raw('set_group_admin_receive_verify', { group_id: 10001, user_id: 10002 });
export const uploadedFile: Promise<UploadFileStreamComplete> = http.uploadFileStream(
new Blob(['file']),
);
export const download: AsyncGenerator<Uint8Array, DownloadFileStreamComplete, void> =
ws.downloadFileStream({ file_id: 'media' }, { action: 'download_file_image_stream' });
export const rawFrames: AsyncIterableIterator<
StreamApiResponse<ActionResult<'test_download_stream'>>
> = http.requestStream('test_download_stream', {});
for await (const item of ws.stream('download_file_stream', { file: '/remote/file' })) {
if (item.type === 'stream' && item.data_type === 'file_chunk') {
const encoded: string = item.data;
void encoded;
}
}
// @ts-expect-error single-response actions use request()/call()
http.stream('get_status', {});
// @ts-expect-error uploads accept bytes rather than text chunks
http.uploadFileStream('text');
// @ts-expect-error downloads require a file or file ID
ws.downloadFileStream({});
const feeds = await http.raw('get_qzone_feeds', { page_num: '2', count: 10 });
const more: boolean = feeds.has_more;
const author: number = feeds.feeds[0].uin;
export const legacyFeedHtml: string = feeds.feeds[0].html;
export const feedContent: string | null | undefined = feeds.feeds[0].content;
export const feedPhotos: string[] | null | undefined = feeds.feeds[0].images;
export const feedLikeCount: number | null | undefined = feeds.feeds[0].like_count;
export const feedCursor: string | null | undefined = feeds.next_cursor;
if (feedCursor) {
await http.raw('get_qzone_feeds', { cursor: feedCursor, count: 10 });
}
export const legacyFeedsResult: ActionResult<'get_qzone_feeds'> = {
feeds: [{ uin: 10002, nickname: 'User', time: 1, appid: 311, key: 'post', html: '' }],
has_more: false,
};
// @ts-expect-error continuation tokens must be strings
http.raw('get_qzone_feeds', { cursor: 123 });
const qzoneReply = await http.raw('comment_qzone', actionParams.comment_qzone);
export const qzoneReplyId: string = qzoneReply.comment_id;
export const qzoneParentId: string | undefined = qzoneReply.parent_comment_id;
export const qzoneParentUin: number | undefined = qzoneReply.parent_comment_uin;
export const legacyQzoneComment: ActionParams<'comment_qzone'> = {
tid: 'post',
content: 'Comment',
images: ['https://example.com/image.png'],
};
export const legacyQzoneResult: ActionResult<'comment_qzone'> = { comment_id: '' };
const qzoneProfile = await http.raw('get_qzone_profile', actionParams.get_qzone_profile);
export const qzoneName: string = qzoneProfile.name;
export const qzoneDescription: string = qzoneProfile.description;
export const ownQzoneProfile = http.raw('get_qzone_profile', {});
export const qzoneProfileUpdate: Promise<null> = ws.raw('set_qzone_profile', {
name: 'Space',
description: '',
});
export const qzoneNameOnly: ActionParams<'set_qzone_profile'> = { name: 'Space' };
export const qzoneDescriptionOnly: ActionParams<'set_qzone_profile'> = { description: '' };
export const qzoneScalarFields: ActionParams<'set_qzone_profile'> = {
name: 123,
description: false,
};
// @ts-expect-error a space profile patch requires at least one field
export const emptyQzoneProfilePatch: ActionParams<'set_qzone_profile'> = {};
export const invalidQzoneProfilePatch: ActionParams<'set_qzone_profile'> = {
// @ts-expect-error profile fields accept string, number or boolean primitives
description: [],
};
const feedDetail = await http.raw('get_qzone_feed_detail', actionParams.get_qzone_feed_detail);
export const fullQzoneFeed: JsonObject = feedDetail.feed;
export const feedDetailCursor: string | null = feedDetail.attach_info;
export const feedDetailCard = await http.raw('get_qzone_share_ark', { feed: feedDetail.feed });
// @ts-expect-error querying a feed requires a post ID
http.raw('get_qzone_feed_detail', {});
// @ts-expect-error the post ID is hexadecimal text, not an array
http.raw('get_qzone_feed_detail', { tid: [] });
const publicPage = await http.raw('get_qzone_public_feeds', {});
export const publicCells: JsonObject[] = publicPage.feeds;
export const publicHasMore: boolean = publicPage.has_more;
export const publicCursor: string | null = publicPage.next_cursor;
if (publicPage.has_more && publicPage.next_cursor) {
await http.raw('get_qzone_public_feeds', { cursor: publicPage.next_cursor });
}
declare const publicFeed: JsonObject;
declare const publicComment: JsonObject;
export const publicReplyDefaults: ActionParams<'get_qzone_public_replies'> = {
feed: publicFeed,
comment: publicComment,
};
export const publicUpvoteDefault: ActionParams<'set_qzone_public_upvote'> = { feed: publicFeed };
export const publicLikeDefault: ActionParams<'set_qzone_public_like'> = { feed: publicFeed };
export const publicFollowDefault: ActionParams<'set_qzone_public_follow'> = { feed: publicFeed };
export const publicCommentLikeDefault: ActionParams<'set_qzone_public_comment_like'> = {
feed: publicFeed,
comment: publicComment,
};
export const publicReplyLike: ActionParams<'set_qzone_public_comment_like'> = {
feed: publicFeed,
comment: publicComment,
reply: { id: '18446744073709551615', like: { id: 'reply-like-key' } },
enable: false,
};
export const publicReplyToComment: ActionParams<'reply_qzone_public_feed'> = {
feed: publicFeed,
comment: publicComment,
content: 'Reply',
};
export const publicForwardDefault: ActionParams<'forward_qzone_public_feed'> = { feed: publicFeed };
export const publicReplyResult: JsonObject = await ws.raw(
'reply_qzone_public_feed',
actionParams.reply_qzone_public_feed,
);
// @ts-expect-error follow-up comments require attach_info as the cursor
export const publicCommentsWithoutCursor: ActionParams<'get_qzone_public_comments'> = {
feed: publicFeed,
};
export const invalidPublicContent: ActionParams<'comment_qzone_public_feed'> = {
feed: publicFeed,
// @ts-expect-error public content does not coerce numeric primitives
content: 123,
};
export const invalidPublicRichItems: ActionParams<'comment_qzone_public_feed'> = {
feed: publicFeed,
// @ts-expect-error rich-text arrays contain JSON objects, not strings
content: ['text'],
};
const speech = await ws.raw('get_record_text', { message_id: -42 });
const transcription: string = speech.text;
const flash = await http.raw('get_fileset_info', { fileset_id: 'files' });
const bytes: number = flash.file_list[0].size;
const envelope = await ws.rawResponse('get_fileset_info', actionParams.get_fileset_info);
const filesetId: string = envelope.data.fileset_id;
const album = await http.raw('get_qun_album_list', actionParams.get_qun_album_list);
const count: string = album.album_list[0].upload_number;
const cover: string | null | undefined = album.album_list[0].cover?.image?.defaultUrl?.url;
const upload = await ws.raw('upload_file_stream', { stream_id: 'fixture', verify_only: true });
if (upload.type === 'response') {
const checksum: string = upload.sha256;
const received: number = upload.received_chunks;
void [checksum, received];
} else if (upload.type === 'stream') {
const status: 'file_created' | 'chunk_received' = upload.status;
void status;
}
declare const frame: ActionResult<'download_file_stream'>;
if (frame.data_type === 'file_chunk') {
const chunk: string = frame.data;
const index: number = frame.index;
void [chunk, index];
}
// Extra JSON fields and all accepted flash-task forms remain available.
export const extra: ActionParams<'send_qzone_msg'> = { content: 'Post', vendor_option: true };
export const singlePath: ActionParams<'create_flash_task'> = { files: '/fixture' };
export const namedFile: ActionParams<'create_flash_task'> = {
files: { file: '/fixture', name: 'file' },
};
export const legacyScalars: ActionParams<'modify_custom_face'> = { emoji_id: 123, desc: false };
export const onePermission: ActionParams<'set_group_member_permissions'> = {
group_id: 10001,
allow_member_upload_album: false,
};
export const completed: ActionResult<'upload_file_stream'> = {
type: 'response',
stream_id: 'fixture',
status: 'file_complete',
received_chunks: 1,
total_chunks: 1,
file_path: '/fixture',
file_name: 'file',
file_size: 1,
sha256: 'fixture',
};
export const streamError: ActionResult<'download_file_stream'> = {
type: 'error',
data_type: 'error',
};
// @ts-expect-error publishing requires content
export const missingContent: ActionParams<'send_qzone_msg'> = {};
// @ts-expect-error flash files must be paths or named file objects
export const invalidFlashFile: ActionParams<'create_flash_task'> = { files: [123] };
// @ts-expect-error a permission patch needs at least one flag
export const missingPermission: ActionParams<'set_group_member_permissions'> = { group_id: 10001 };
export const invalidPolicy: ActionParams<'set_group_member_invite_policy'> = {
group_id: 10001,
// @ts-expect-error the policy is a closed server enum
policy: 'admin_only',
};
// @ts-expect-error uint64 album counters are returned as strings
export const invalidCount: ActionResult<'get_qun_album_list'>['album_list'][number]['upload_number'] = 1;
// @ts-expect-error voice transcription is an object with a text field
export const invalidSpeech: ActionResult<'fetch_ptt_text'> = 'Speech';
// The open action namespace and JSON escape hatch remain available.
declare const dynamicAction: string;
declare const dynamicParams: JsonObject;
const custom: Promise<JsonValue> = http.raw('consumer_extension', { custom: true });
const dynamic: Promise<JsonValue> = ws.raw(dynamicAction, dynamicParams);
export {
more,
author,
transcription,
bytes,
filesetId,
count,
cover,
custom,
dynamic,
actionResults,
};
export type NewParams = ActionParams<'send_qzone_msg'>;
export type NewResult = ActionResult<'send_qzone_msg'>;
+103
View File
@@ -0,0 +1,103 @@
import {
createEventContext,
isGroupMessageEvent,
quoteMessage,
text,
type EventHandler,
type EventMiddleware,
type EventWaitOptions,
type MediaSource,
type OneBotGroupMessageEvent,
type OneBotMessageEvent,
type SendMessageResult,
type SnowLumaEvent,
type SnowLumaEventContext,
type SnowLumaEventContextWithQuote,
type SnowLumaHttpClient,
type SnowLumaWebSocketClient,
} from '@snowluma/sdk';
import { localFile } from '@snowluma/sdk/node';
import type { EventWaitOptions as ClientWaitOptions } from '@snowluma/sdk/client';
import type { SendImageOptions } from '@snowluma/sdk/types';
import { quoteMessage as messageQuote } from '@snowluma/sdk/messages';
declare const http: SnowLumaHttpClient;
declare const ws: SnowLumaWebSocketClient;
declare const event: OneBotMessageEvent;
const source: MediaSource = await localFile('./picture.png');
const options: SendImageOptions = { image: { summary: 'picture' }, chunkSize: 16384 };
const sent: Promise<SendMessageResult> = http.sendGroupImage(123, source, options);
const sentPrivate: Promise<SendMessageResult> = ws.sendPrivateVideo(456, new Blob(['bytes']));
const file: Promise<{ file_id: string | null }> = ws.sendPrivateFile(456, new Uint8Array([1]));
ws.sendGroupFile(
123,
{ source: new Blob(['bytes']), filename: 'notes.txt' },
{ folderId: '/docs' },
);
// @ts-expect-error Strings remain server paths; opening a local file must be explicit.
http.sendPrivateImage(456, './picture.png');
// @ts-expect-error Media attributes remain checked.
http.sendGroupImage(123, source, { image: { subType: 'invalid' } });
const oldContext: SnowLumaEventContext<OneBotMessageEvent> = {
event,
client: http,
stopped: false,
stopPropagation() {},
async reply() {},
async approve() {},
async reject() {},
async quickOperation() {},
};
const oldHandler: EventHandler<OneBotMessageEvent> = async (_event, context) => {
await context.reply('unchanged');
};
oldHandler(event, oldContext);
ws.onMessage(oldHandler);
const oldMiddleware: EventMiddleware = async (_event, context, next) => {
await context.reply('unchanged');
await next();
};
oldMiddleware(event, oldContext, async () => {});
ws.use(oldMiddleware);
const context: SnowLumaEventContextWithQuote<OneBotMessageEvent> = createEventContext(event, http);
const quoted: Promise<SendMessageResult> = context.quote(text('hello'));
ws.onMessage(async (_event, ctx) => {
await ctx.quote('hello');
});
ws.onGroupMessage(async (_event, ctx) => {
await ctx.quote('hello');
});
ws.use(async (_event, ctx, next) => {
await ctx.quote('hello');
await next();
});
ws.command('ping', async (_event, ctx) => {
await ctx.quote('pong');
});
http.sendGroupMessage(123, quoteMessage(-42, 'body'));
http.sendPrivateMessage(456, messageQuote(-42, text('body')));
const waitOptions: EventWaitOptions & ClientWaitOptions = {
timeoutMs: 100,
signal: new AbortController().signal,
};
const group: Promise<OneBotGroupMessageEvent> = ws.waitForEvent(isGroupMessageEvent, waitOptions);
const groupMessage: Promise<OneBotGroupMessageEvent> = ws.waitForMessage(isGroupMessageEvent);
const message: Promise<OneBotMessageEvent> = ws.waitForMessage(
(e) => e.user_id === 456,
waitOptions,
);
const anyEvent: Promise<SnowLumaEvent> = ws.waitForEvent(waitOptions);
ws.waitForEvent(undefined, waitOptions);
ws.waitForMessage(waitOptions);
// @ts-expect-error A filter without a type guard does not promise group fields.
const wrong: Promise<OneBotGroupMessageEvent> = ws.waitForEvent(() => true);
// @ts-expect-error Wait predicates are synchronous.
ws.waitForMessage(async () => true);
export { sent, sentPrivate, file, quoted, group, groupMessage, message, anyEvent, wrong };
+495
View File
@@ -0,0 +1,495 @@
import { describe, expect, it } from 'vitest';
import {
isBotStatusEvent,
isGroupMessageEvent,
isMessageEvent,
isMetaEvent,
isNoticeEvent,
isPrivateMessageEvent,
isRequestEvent,
noticeType,
requestType,
type OneBotGroupMessageEvent,
type OneBotMetaEvent,
type OneBotNoticeEvent,
type OneBotPrivateMessageEvent,
type OneBotRequestEvent,
type SnowLumaEvent,
} from '../src';
const PRIVATE_MESSAGE: OneBotPrivateMessageEvent = {
time: 1_700_000_001,
self_id: 10000,
post_type: 'message',
message_type: 'private',
sub_type: 'friend',
message_id: 11,
message_seq: 12,
user_id: 20001,
message: [{ type: 'text', data: { text: 'hi' } }],
raw_message: 'hi',
font: 0,
sender: { user_id: 20001, nickname: 'alice' },
};
const PRIVATE_SENT: OneBotPrivateMessageEvent = {
time: 1_700_000_002,
self_id: 10000,
post_type: 'message_sent',
message_type: 'private',
sub_type: 'friend',
message_id: 13,
user_id: 20002,
message: 'echo',
raw_message: 'echo',
font: 0,
sender: { user_id: 10000, nickname: 'bot' },
};
const GROUP_MESSAGE: OneBotGroupMessageEvent = {
time: 1_700_000_003,
self_id: 10000,
post_type: 'message',
message_type: 'group',
sub_type: 'normal',
message_id: 21,
message_seq: 22,
group_id: 30001,
user_id: 20003,
message: [{ type: 'text', data: { text: 'hey' } }],
raw_message: 'hey',
font: 0,
sender: { user_id: 20003, nickname: 'bob', card: 'Bobby', role: 'member' },
};
const GROUP_SENT: OneBotGroupMessageEvent = {
time: 1_700_000_004,
self_id: 10000,
post_type: 'message_sent',
message_type: 'group',
sub_type: 'normal',
message_id: 23,
group_id: 30002,
user_id: 10000,
message: 'sent',
raw_message: 'sent',
font: 0,
sender: { user_id: 10000, nickname: 'bot' },
};
const NOTICE: OneBotNoticeEvent = {
time: 1_700_000_005,
self_id: 10000,
post_type: 'notice',
notice_type: 'group_increase',
};
const REQUEST: OneBotRequestEvent = {
time: 1_700_000_006,
self_id: 10000,
post_type: 'request',
request_type: 'friend',
flag: 'flag-friend-1',
user_id: 20004,
comment: 'please',
};
const META: OneBotMetaEvent = {
time: 1_700_000_007,
self_id: 10000,
post_type: 'meta_event',
meta_event_type: 'heartbeat',
};
const UNKNOWN: SnowLumaEvent = {
time: 1_700_000_008,
self_id: 10000,
post_type: 'unknown',
};
describe('isMessageEvent', () => {
it('accepts inbound private and group messages', () => {
expect(isMessageEvent(PRIVATE_MESSAGE)).toBe(true);
expect(isMessageEvent(GROUP_MESSAGE)).toBe(true);
});
it('accepts message_sent private and group events', () => {
expect(isMessageEvent(PRIVATE_SENT)).toBe(true);
expect(isMessageEvent(GROUP_SENT)).toBe(true);
});
it('rejects message post types whose message_type is not private or group', () => {
const guild: SnowLumaEvent = {
time: 9,
self_id: 10000,
post_type: 'message',
message_type: 'guild',
};
const sentChannel: SnowLumaEvent = {
time: 10,
self_id: 10000,
post_type: 'message_sent',
message_type: 'channel',
};
const missingType: SnowLumaEvent = {
time: 11,
self_id: 10000,
post_type: 'message',
};
expect(isMessageEvent(guild)).toBe(false);
expect(isMessageEvent(sentChannel)).toBe(false);
expect(isMessageEvent(missingType)).toBe(false);
});
it('rejects non-message events even when message_type is present', () => {
const noticeShaped: SnowLumaEvent = {
time: 12,
self_id: 10000,
post_type: 'notice',
notice_type: 'notify',
message_type: 'private',
};
expect(isMessageEvent(NOTICE)).toBe(false);
expect(isMessageEvent(REQUEST)).toBe(false);
expect(isMessageEvent(META)).toBe(false);
expect(isMessageEvent(UNKNOWN)).toBe(false);
expect(isMessageEvent(noticeShaped)).toBe(false);
});
});
describe('isPrivateMessageEvent', () => {
it('accepts private inbound and sent messages', () => {
expect(isPrivateMessageEvent(PRIVATE_MESSAGE)).toBe(true);
expect(isPrivateMessageEvent(PRIVATE_SENT)).toBe(true);
});
it('rejects group messages and non-message events', () => {
expect(isPrivateMessageEvent(GROUP_MESSAGE)).toBe(false);
expect(isPrivateMessageEvent(GROUP_SENT)).toBe(false);
expect(isPrivateMessageEvent(NOTICE)).toBe(false);
expect(isPrivateMessageEvent(REQUEST)).toBe(false);
expect(isPrivateMessageEvent(META)).toBe(false);
});
it('rejects private message_type when post_type is not a message kind', () => {
const leftover: SnowLumaEvent = {
time: 13,
self_id: 10000,
post_type: 'notice',
notice_type: 'friend_add',
message_type: 'private',
};
expect(isPrivateMessageEvent(leftover)).toBe(false);
});
});
describe('isGroupMessageEvent', () => {
it('accepts group inbound and sent messages', () => {
expect(isGroupMessageEvent(GROUP_MESSAGE)).toBe(true);
expect(isGroupMessageEvent(GROUP_SENT)).toBe(true);
});
it('rejects private messages and non-message events', () => {
expect(isGroupMessageEvent(PRIVATE_MESSAGE)).toBe(false);
expect(isGroupMessageEvent(PRIVATE_SENT)).toBe(false);
expect(isGroupMessageEvent(NOTICE)).toBe(false);
expect(isGroupMessageEvent(REQUEST)).toBe(false);
expect(isGroupMessageEvent(META)).toBe(false);
});
it('rejects group message_type when post_type is not a message kind', () => {
const leftover: SnowLumaEvent = {
time: 14,
self_id: 10000,
post_type: 'request',
request_type: 'group',
flag: 'flag-x',
message_type: 'group',
};
expect(isGroupMessageEvent(leftover)).toBe(false);
});
});
describe('isNoticeEvent', () => {
it('accepts notice events with a string notice_type', () => {
expect(isNoticeEvent(NOTICE)).toBe(true);
expect(
isNoticeEvent({
time: 15,
self_id: 10000,
post_type: 'notice',
notice_type: '',
}),
).toBe(true);
});
it('rejects notice post_type when notice_type is not a string', () => {
const missing: SnowLumaEvent = {
time: 16,
self_id: 10000,
post_type: 'notice',
};
const numeric: SnowLumaEvent = {
time: 17,
self_id: 10000,
post_type: 'notice',
notice_type: 7,
};
const nulled: SnowLumaEvent = {
time: 18,
self_id: 10000,
post_type: 'notice',
notice_type: null,
};
expect(isNoticeEvent(missing)).toBe(false);
expect(isNoticeEvent(numeric)).toBe(false);
expect(isNoticeEvent(nulled)).toBe(false);
});
it('accepts bot_status notices and rejects incomplete ones', () => {
expect(
isBotStatusEvent({
time: 19,
self_id: 10000,
post_type: 'notice',
notice_type: 'bot_status',
sub_type: 'online',
user_id: 10000,
}),
).toBe(true);
expect(
isBotStatusEvent({
time: 20,
self_id: 10000,
post_type: 'notice',
notice_type: 'bot_status',
sub_type: 'offline',
user_id: 10000,
}),
).toBe(true);
expect(isBotStatusEvent(NOTICE)).toBe(false);
expect(
isBotStatusEvent({
time: 21,
self_id: 10000,
post_type: 'notice',
notice_type: 'bot_status',
sub_type: 'flap',
user_id: 10000,
}),
).toBe(false);
});
it('rejects other post types', () => {
expect(isNoticeEvent(PRIVATE_MESSAGE)).toBe(false);
expect(isNoticeEvent(REQUEST)).toBe(false);
expect(isNoticeEvent(META)).toBe(false);
expect(isNoticeEvent(UNKNOWN)).toBe(false);
});
});
describe('isRequestEvent', () => {
it('accepts request events with string request_type and flag', () => {
expect(isRequestEvent(REQUEST)).toBe(true);
expect(
isRequestEvent({
time: 19,
self_id: 10000,
post_type: 'request',
request_type: 'group',
sub_type: 'add',
flag: '',
group_id: 30003,
}),
).toBe(true);
});
it('rejects request post_type when request_type or flag is not a string', () => {
const missingFlag: SnowLumaEvent = {
time: 20,
self_id: 10000,
post_type: 'request',
request_type: 'friend',
};
const numericFlag: SnowLumaEvent = {
time: 21,
self_id: 10000,
post_type: 'request',
request_type: 'friend',
flag: 99,
};
const missingType: SnowLumaEvent = {
time: 22,
self_id: 10000,
post_type: 'request',
flag: 'flag-2',
};
const numericType: SnowLumaEvent = {
time: 23,
self_id: 10000,
post_type: 'request',
request_type: 1,
flag: 'flag-3',
};
const nullFlag: SnowLumaEvent = {
time: 24,
self_id: 10000,
post_type: 'request',
request_type: 'friend',
flag: null,
};
expect(isRequestEvent(missingFlag)).toBe(false);
expect(isRequestEvent(numericFlag)).toBe(false);
expect(isRequestEvent(missingType)).toBe(false);
expect(isRequestEvent(numericType)).toBe(false);
expect(isRequestEvent(nullFlag)).toBe(false);
});
it('rejects other post types even when request fields are present', () => {
const leftover: SnowLumaEvent = {
time: 25,
self_id: 10000,
post_type: 'notice',
notice_type: 'group_ban',
request_type: 'group',
flag: 'flag-4',
};
expect(isRequestEvent(PRIVATE_MESSAGE)).toBe(false);
expect(isRequestEvent(NOTICE)).toBe(false);
expect(isRequestEvent(META)).toBe(false);
expect(isRequestEvent(leftover)).toBe(false);
});
});
describe('isMetaEvent', () => {
it('accepts meta_event events with a string meta_event_type', () => {
expect(isMetaEvent(META)).toBe(true);
expect(
isMetaEvent({
time: 26,
self_id: 10000,
post_type: 'meta_event',
meta_event_type: 'lifecycle',
}),
).toBe(true);
});
it('rejects meta_event post_type when meta_event_type is not a string', () => {
const missing: SnowLumaEvent = {
time: 27,
self_id: 10000,
post_type: 'meta_event',
};
const numeric: SnowLumaEvent = {
time: 28,
self_id: 10000,
post_type: 'meta_event',
meta_event_type: 0,
};
const nulled: SnowLumaEvent = {
time: 29,
self_id: 10000,
post_type: 'meta_event',
meta_event_type: null,
};
expect(isMetaEvent(missing)).toBe(false);
expect(isMetaEvent(numeric)).toBe(false);
expect(isMetaEvent(nulled)).toBe(false);
});
it('rejects other post types', () => {
expect(isMetaEvent(PRIVATE_MESSAGE)).toBe(false);
expect(isMetaEvent(NOTICE)).toBe(false);
expect(isMetaEvent(REQUEST)).toBe(false);
expect(isMetaEvent(UNKNOWN)).toBe(false);
});
});
describe('noticeType', () => {
it('matches notice events by notice_type', () => {
const isIncrease = noticeType('group_increase');
const isDecrease = noticeType('group_decrease');
expect(isIncrease(NOTICE)).toBe(true);
expect(isDecrease(NOTICE)).toBe(false);
expect(
isIncrease({
time: 30,
self_id: 10000,
post_type: 'notice',
notice_type: 'group_decrease',
}),
).toBe(false);
});
it('rejects events that fail isNoticeEvent', () => {
const isIncrease = noticeType('group_increase');
const missingType: SnowLumaEvent = {
time: 31,
self_id: 10000,
post_type: 'notice',
};
const leftover: SnowLumaEvent = {
time: 32,
self_id: 10000,
post_type: 'message',
message_type: 'private',
notice_type: 'group_increase',
};
expect(isIncrease(missingType)).toBe(false);
expect(isIncrease(leftover)).toBe(false);
expect(isIncrease(REQUEST)).toBe(false);
expect(isIncrease(PRIVATE_MESSAGE)).toBe(false);
});
});
describe('requestType', () => {
it('matches request events by request_type', () => {
const isFriend = requestType('friend');
const isGroup = requestType('group');
expect(isFriend(REQUEST)).toBe(true);
expect(isGroup(REQUEST)).toBe(false);
expect(
isFriend({
time: 33,
self_id: 10000,
post_type: 'request',
request_type: 'group',
flag: 'flag-group-1',
}),
).toBe(false);
});
it('rejects events that fail isRequestEvent', () => {
const isFriend = requestType('friend');
const missingFlag: SnowLumaEvent = {
time: 34,
self_id: 10000,
post_type: 'request',
request_type: 'friend',
};
const leftover: SnowLumaEvent = {
time: 35,
self_id: 10000,
post_type: 'notice',
notice_type: 'friend_add',
request_type: 'friend',
flag: 'flag-5',
};
expect(isFriend(missingFlag)).toBe(false);
expect(isFriend(leftover)).toBe(false);
expect(isFriend(NOTICE)).toBe(false);
expect(isFriend(META)).toBe(false);
});
});
+308
View File
@@ -0,0 +1,308 @@
import { describe, expect, expectTypeOf, it, vi } from 'vitest';
import type { GroupSystemMessage, QzoneMsgDetailResult, QzoneProfileResult } from '../src';
import {
SnowLumaAbortError,
SnowLumaApiError,
SnowLumaAuthError,
SnowLumaHttpClient,
SnowLumaParseError,
} from '../src';
describe('SnowLumaHttpClient', () => {
it('retains independent group request actors and supports old servers', async () => {
const legacy: GroupSystemMessage = {
group_id: 90001,
group_name: 'group',
request_id: 1,
requester_uin: 10002,
requester_nick: 'member',
invitor_uin: 10004,
invitor_nick: 'inviter',
message: '',
checked: true,
flag: 'request-1',
};
const current: GroupSystemMessage = {
...legacy,
target_uin: 10002,
target_status: 'known',
operator_uin: 10003,
operator_status: 'known',
invitor_status: 'known',
};
const fetchImpl = vi.fn(
async () =>
new Response(JSON.stringify({ status: 'ok', retcode: 0, data: [current, legacy] })),
);
const client = new SnowLumaHttpClient({ fetch: fetchImpl });
const result = await client.getGroupSystemMessages({ groupId: 90001 });
expectTypeOf(result).toEqualTypeOf<GroupSystemMessage[]>();
expect(result).toEqual([current, legacy]);
expect(result[1]).not.toHaveProperty('invitor_status');
});
it('reads typed space profiles for the current and an explicit account', async () => {
const data: QzoneProfileResult = { name: 'Space', description: 'Description' };
const fetchImpl = vi
.fn()
.mockImplementation(
async () => new Response(JSON.stringify({ status: 'ok', retcode: 0, data })),
);
const client = new SnowLumaHttpClient({ fetch: fetchImpl });
const own = await client.raw('get_qzone_profile', {});
const other = await client.raw('get_qzone_profile', { target_uin: '10002' });
expectTypeOf(own).toEqualTypeOf<QzoneProfileResult>();
expect(other).toEqual(data);
expect(
fetchImpl.mock.calls.map((call) => JSON.parse((call[1] as RequestInit).body as string)),
).toEqual([
{ action: 'get_qzone_profile', params: {} },
{ action: 'get_qzone_profile', params: { target_uin: '10002' } },
]);
});
it('preserves omitted and empty space profile fields and returns null', async () => {
const fetchImpl = vi
.fn()
.mockImplementation(
async () => new Response(JSON.stringify({ status: 'ok', retcode: 0, data: null })),
);
const client = new SnowLumaHttpClient({ fetch: fetchImpl });
const result = await client.raw('set_qzone_profile', { name: 'Space' });
expectTypeOf(result).toEqualTypeOf<null>();
expect(result).toBeNull();
await expect(client.raw('set_qzone_profile', { description: '' })).resolves.toBeNull();
expect(
fetchImpl.mock.calls.map((call) => JSON.parse((call[1] as RequestInit).body as string)),
).toEqual([
{ action: 'set_qzone_profile', params: { name: 'Space' } },
{ action: 'set_qzone_profile', params: { description: '' } },
]);
});
it('propagates space profile update failures', async () => {
const fetchImpl = vi.fn(
async () =>
new Response(
JSON.stringify({
status: 'failed',
retcode: 1200,
data: null,
wording: 'Qzone requires verification',
}),
),
);
const client = new SnowLumaHttpClient({ fetch: fetchImpl });
const request = client.raw('set_qzone_profile', { name: 'Space' });
await expect(request).rejects.toMatchObject({
name: 'SnowLumaApiError',
retcode: 1200,
wording: 'Qzone requires verification',
});
await expect(request).rejects.toBeInstanceOf(SnowLumaApiError);
});
it('reads a typed Qzone comment page through raw', async () => {
const data: QzoneMsgDetailResult = {
tid: 'T1',
content: 'Post',
comment_num: 1,
comments: [
{
uin: 20002,
name: 'Reader',
time: 1700000000,
content: 'Hi',
comment_id: '0',
replies: [],
},
],
};
const fetchImpl = vi.fn(
async () => new Response(JSON.stringify({ status: 'ok', retcode: 0, data })),
);
const client = new SnowLumaHttpClient({ fetch: fetchImpl });
const params = { tid: 'T1', target_uin: 10001, pos: 0, num: 20 };
const result = await client.raw('get_qzone_msg_detail', params);
expectTypeOf(result).toEqualTypeOf<QzoneMsgDetailResult>();
expect(result).toEqual(data);
expect(fetchImpl).toHaveBeenCalledWith(
'http://127.0.0.1:3000/',
expect.objectContaining({
body: JSON.stringify({ action: 'get_qzone_msg_detail', params }),
}),
);
});
it('requests group share links with default and explicit options', async () => {
const url = 'https://qm.qq.com/q/example';
const fetchImpl = vi
.fn()
.mockImplementation(
async () =>
new Response(JSON.stringify({ status: 'ok', retcode: 0, data: { url } })),
);
const client = new SnowLumaHttpClient({ fetch: fetchImpl });
await expect(client.getGroupShareLink(12345)).resolves.toEqual({ url });
await expect(
client.getGroupShareLink(12345, {
needShortUrl: false,
srcId: 0,
additionalParam: 'source=二维码',
}),
).resolves.toEqual({ url });
expect(
fetchImpl.mock.calls.map((call) => JSON.parse((call[1] as RequestInit).body as string)),
).toEqual([
{ action: 'get_group_share_link', params: { group_id: 12345 } },
{
action: 'get_group_share_link',
params: {
group_id: 12345,
need_short_url: false,
src_id: 0,
additional_param: 'source=二维码',
},
},
]);
});
it('maps extended group-info refresh options to OneBot field names', async () => {
const fetchImpl = vi.fn().mockImplementation(
async () =>
new Response(
JSON.stringify({
status: 'ok',
retcode: 0,
data: null,
}),
),
);
const client = new SnowLumaHttpClient({ fetch: fetchImpl });
await client.getGroupInfoEx(123456, { noCache: true });
await client.getGroupDetailInfo(123456, { noCache: true });
expect(
fetchImpl.mock.calls.map((call) => JSON.parse((call[1] as RequestInit).body as string)),
).toEqual([
{ action: 'get_group_info_ex', params: { group_id: 123456, no_cache: true } },
{ action: 'get_group_detail_info', params: { group_id: 123456, no_cache: true } },
]);
});
it('maps system-face catalog helpers to their OneBot actions', async () => {
const fetchImpl = vi.fn().mockImplementation(
async () =>
new Response(
JSON.stringify({
status: 'ok',
retcode: 0,
data: null,
}),
),
);
const client = new SnowLumaHttpClient({ fetch: fetchImpl });
await client.fetchSysFaces(true);
await client.fetchFaceEntity(392, { refresh: true });
await client.searchSysFaces('dragon');
await client.fetchSuperFaceId(392);
expect(
fetchImpl.mock.calls.map((call) => JSON.parse((call[1] as RequestInit).body as string)),
).toEqual([
{ action: 'fetch_sys_faces', params: { refresh: true } },
{ action: 'fetch_face_entity', params: { face_id: 392, refresh: true } },
{ action: 'search_sys_faces', params: { query: 'dragon' } },
{ action: 'fetch_super_face_id', params: { face_id: 392, refresh: false } },
]);
});
it('maps group-announcement options to their OneBot field names', async () => {
const fetchImpl = vi.fn().mockResolvedValue(
new Response(
JSON.stringify({
status: 'ok',
retcode: 0,
data: null,
}),
),
);
const client = new SnowLumaHttpClient({ fetch: fetchImpl });
await client.sendGroupNotice(941657197, 'welcome', {
pinned: 1,
type: 20,
sendToNewMembers: true,
isShowEditCard: 0,
tipWindowType: 0,
confirmRequired: 0,
});
const init = fetchImpl.mock.calls[0]![1] as RequestInit;
expect(JSON.parse(init.body as string)).toEqual({
action: '_send_group_notice',
params: {
group_id: 941657197,
content: 'welcome',
pinned: 1,
type: 20,
send_to_new_members: true,
is_show_edit_card: 0,
tip_window_type: 0,
confirm_required: 0,
},
});
});
it('throws SnowLumaAuthError for auth retcodes', async () => {
const fetchImpl = vi.fn().mockResolvedValue(
new Response(
JSON.stringify({
status: 'failed',
retcode: 1401,
data: null,
wording: 'unauthorized',
}),
),
);
const client = new SnowLumaHttpClient({ fetch: fetchImpl });
await expect(client.getStatus()).rejects.toBeInstanceOf(SnowLumaAuthError);
});
it('throws SnowLumaParseError for non-JSON responses', async () => {
const fetchImpl = vi.fn().mockResolvedValue(new Response('not json', { status: 200 }));
const client = new SnowLumaHttpClient({ fetch: fetchImpl });
await expect(client.getStatus()).rejects.toBeInstanceOf(SnowLumaParseError);
});
it('supports caller AbortSignal cancellation', async () => {
const fetchImpl = vi.fn(
(_url: string | URL | Request, init?: RequestInit) =>
new Promise<Response>((_resolve, reject) => {
const signal = init?.signal;
const abort = () => reject(new DOMException('aborted', 'AbortError'));
if (signal?.aborted) {
abort();
}
signal?.addEventListener('abort', abort, { once: true });
}),
);
const client = new SnowLumaHttpClient({ fetch: fetchImpl });
const controller = new AbortController();
const request = client.getStatus({ signal: controller.signal });
controller.abort();
await expect(request).rejects.toBeInstanceOf(SnowLumaAbortError);
});
});
+222
View File
@@ -0,0 +1,222 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
SnowLumaAbortError,
SnowLumaApiError,
SnowLumaAuthError,
SnowLumaHttpClient,
SnowLumaParseError,
SnowLumaTimeoutError,
} from '../src/index.js';
const data = { type: 'stream', data_type: 'data_chunk', data: '中文🙂' };
const complete = { type: 'response', data_type: 'data_complete', data: 'done' };
const frame = (payload: unknown) => ({
status: 'ok',
retcode: 0,
stream: 'stream-action',
data: payload,
echo: 'stream',
});
const wire = (payload: unknown) => JSON.stringify(payload) + '\r\n\r\n';
async function collect<T>(stream: AsyncIterable<T>): Promise<T[]> {
const results: T[] = [];
for await (const result of stream) {
results.push(result);
}
return results;
}
function clientFor(body: BodyInit | null) {
return new SnowLumaHttpClient({ fetch: async () => new Response(body) });
}
afterEach(() => vi.useRealTimers());
describe('HTTP streaming', () => {
it('does not send an already-aborted request', async () => {
const fetch = vi.fn();
const client = new SnowLumaHttpClient({ fetch });
await expect(
client.stream('test_download_stream', {}, { signal: AbortSignal.abort() }).next(),
).rejects.toBeInstanceOf(SnowLumaAbortError);
expect(fetch).not.toHaveBeenCalled();
});
it('honors cancellation between frames buffered in the same HTTP read', async () => {
const controller = new AbortController();
const client = clientFor(wire(frame(data)) + wire(frame(complete)));
const stream = client.stream('test_download_stream', {}, { signal: controller.signal });
await stream.next();
controller.abort();
await expect(stream.next()).rejects.toBeInstanceOf(SnowLumaAbortError);
});
it.each(['one byte', 'one read'] as const)(
'decodes split UTF-8 and delimiters: %s',
async (mode) => {
const bytes = new TextEncoder().encode(
wire(frame(data)) + wire(frame(data)) + wire(frame(complete)),
);
let offset = 0;
const body = new ReadableStream<Uint8Array>({
pull(controller) {
if (offset === bytes.length) {
controller.close();
return;
}
const end = mode === 'one byte' ? offset + 1 : bytes.length;
controller.enqueue(bytes.subarray(offset, end));
offset = end;
},
});
const fetch = vi.fn(async () => new Response(body));
const client = new SnowLumaHttpClient({ fetch, accessToken: 'test-token' });
const results = await collect(
client.requestStream('test_download_stream', {}, { echo: 'stream' }),
);
expect(results.map((result) => result.data)).toEqual([data, data, complete]);
expect(
results.every(
(result) => result.stream === 'stream-action' && result.echo === 'stream',
),
).toBe(true);
expect(fetch).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
headers: expect.objectContaining({ Authorization: 'Bearer test-token' }),
body: JSON.stringify({
action: 'test_download_stream',
params: {},
echo: 'stream',
}),
}),
);
expect(body.locked).toBe(false);
},
);
it('delivers a frame before EOF and cancels the reader when iteration stops', async () => {
const cancel = vi.fn();
const body = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode(wire(frame(data))));
},
cancel,
});
const stream = clientFor(body).stream('test_download_stream', {});
expect(await stream.next()).toMatchObject({ value: data, done: false });
await stream.return();
expect(cancel).toHaveBeenCalledOnce();
expect(body.locked).toBe(false);
});
it.each([
['', 'terminal'],
[wire(frame(data)), 'terminal'],
[wire({ status: 'ok', retcode: 0, data }), 'marker'],
[wire({ status: 'ok', data }), 'envelope'],
[wire(frame({ type: 'error' })), 'frame'],
['{bad json}\r\n\r\n', 'JSON'],
])('rejects incomplete or invalid responses: %s', async (body, message) => {
await expect(collect(clientFor(body).stream('test_download_stream', {}))).rejects.toThrow(
message,
);
});
it('rejects malformed UTF-8', async () => {
await expect(
collect(clientFor(new Uint8Array([0xff])).stream('test_download_stream', {})),
).rejects.toBeInstanceOf(SnowLumaParseError);
});
it('bounds individual frames while allowing multiple frames in one large read', async () => {
const text = wire(frame(data)).repeat(10) + wire(frame(complete));
await expect(
collect(clientFor(text).stream('test_download_stream', {}, { maxBufferBytes: 200 })),
).resolves.toHaveLength(11);
await expect(
collect(clientFor(text).stream('test_download_stream', {}, { maxBufferBytes: 10 })),
).rejects.toThrow('maxBufferBytes');
await expect(
collect(
clientFor('x'.repeat(500)).stream(
'test_download_stream',
{},
{ maxBufferBytes: 10 },
),
),
).rejects.toThrow('maxBufferBytes');
});
it('returns raw failures and throws typed API errors from stream()', async () => {
const failure = { status: 'failed', retcode: 1401, data: null, wording: 'denied' };
expect(
await collect(
clientFor(JSON.stringify(failure)).requestStream('test_download_stream', {}),
),
).toMatchObject([failure]);
await expect(
collect(clientFor(JSON.stringify(failure)).stream('test_download_stream', {})),
).rejects.toBeInstanceOf(SnowLumaAuthError);
const stream = clientFor(wire(frame(data)) + wire({ ...failure, retcode: 1500 })).stream(
'test_download_stream',
{},
);
expect((await stream.next()).value).toEqual(data);
await expect(stream.next()).rejects.toBeInstanceOf(SnowLumaApiError);
});
it.each(['abort', 'timeout'] as const)('releases a pending body read on %s', async (reason) => {
vi.useFakeTimers();
const cancel = vi.fn();
const body = new ReadableStream<Uint8Array>({ cancel });
const controller = new AbortController();
const remove = vi.spyOn(controller.signal, 'removeEventListener');
const stream = clientFor(body).stream(
'test_download_stream',
{},
{ signal: controller.signal, timeoutMs: 100 },
);
const next = expect(stream.next()).rejects.toBeInstanceOf(
reason === 'abort' ? SnowLumaAbortError : SnowLumaTimeoutError,
);
await vi.advanceTimersByTimeAsync(0);
if (reason === 'abort') {
controller.abort();
} else {
await vi.advanceTimersByTimeAsync(100);
}
await next;
expect(cancel).toHaveBeenCalledOnce();
expect(remove).toHaveBeenCalled();
expect(body.locked).toBe(false);
expect(vi.getTimerCount()).toBe(0);
});
it('ends an upload ACK without waiting for a response-type frame or HTTP EOF', async () => {
const ack = {
type: 'stream',
status: 'chunk_received',
stream_id: 'id',
received_chunks: 1,
total_chunks: 2,
};
const cancel = vi.fn();
const body = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode(wire(frame(ack))));
},
cancel,
});
expect(
await collect(clientFor(body).stream('upload_file_stream', { stream_id: 'id' })),
).toEqual([ack]);
expect(cancel).toHaveBeenCalledOnce();
});
});
+382
View File
@@ -0,0 +1,382 @@
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { pathToFileURL } from 'node:url';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
SnowLumaAbortError,
SnowLumaApiError,
SnowLumaHttpClient,
SnowLumaMediaSendError,
SnowLumaTimeoutError,
type JsonObject,
} from '../src/index.js';
import { localFile } from '../src/node/index.js';
function server(
options: {
failUpload?: boolean;
failSend?: boolean;
sendResponse?: (init?: RequestInit) => Promise<Response>;
} = {},
) {
const calls: Array<{ action: string; params: JsonObject }> = [];
let first: JsonObject;
const fetch = vi.fn(async (_input: string | URL | Request, init?: RequestInit) => {
const { action, params } = JSON.parse(String(init?.body)) as {
action: string;
params: JsonObject;
};
calls.push({ action, params });
if (action !== 'upload_file_stream') {
if (options.sendResponse) {
return options.sendResponse(init);
}
return Response.json(
options.failSend
? { status: 'failed', retcode: 123, data: null, wording: 'send rejected' }
: {
status: 'ok',
retcode: 0,
data: action.startsWith('upload_')
? { file_id: 'file-id' }
: { message_id: -42 },
},
);
}
if (params.chunk_index === 0) {
first = params;
}
if (options.failUpload) {
return new Response(
JSON.stringify({ status: 'failed', retcode: 321, data: null }) + '\r\n\r\n',
);
}
const data = params.is_complete
? {
type: 'response',
status: 'file_complete',
stream_id: first.stream_id,
received_chunks: first.total_chunks,
total_chunks: first.total_chunks,
file_size: first.file_size,
file_path: '/server/staged.bin',
file_name: first.filename ?? 'generated.bin',
sha256: 'a'.repeat(64),
}
: {
type: 'stream',
status: 'chunk_received',
stream_id: params.stream_id,
received_chunks: Number(params.chunk_index) + 1,
total_chunks: params.total_chunks,
};
return new Response(
JSON.stringify({ status: 'ok', retcode: 0, stream: 'stream-action', data }) +
'\r\n\r\n',
);
});
return { client: new SnowLumaHttpClient({ fetch }), calls, fetch };
}
describe('one-step media sends', () => {
it.each([
['sendGroupImage', 'send_group_msg', 'group_id', 'image'],
['sendPrivateImage', 'send_private_msg', 'user_id', 'image'],
['sendGroupRecord', 'send_group_msg', 'group_id', 'record'],
['sendPrivateRecord', 'send_private_msg', 'user_id', 'record'],
['sendGroupVideo', 'send_group_msg', 'group_id', 'video'],
['sendPrivateVideo', 'send_private_msg', 'user_id', 'video'],
] as const)(
'%s sends only after every chunk and final ACK',
async (method, action, id, type) => {
const { client, calls } = server();
const progress = vi.fn();
await expect(
client[method](123, new Uint8Array([1, 2, 3]), {
chunkSize: 2,
onProgress: progress,
}),
).resolves.toEqual({ message_id: -42 });
expect(calls.map((call) => call.action)).toEqual([
'upload_file_stream',
'upload_file_stream',
'upload_file_stream',
action,
]);
expect(calls.slice(0, 2).map((call) => call.params.chunk_data)).toEqual([
'AQI=',
'Aw==',
]);
expect(calls[2].params.is_complete).toBe(true);
expect(calls[3].params).toEqual({
[id]: 123,
message: [{ type, data: { file: '/server/staged.bin' } }],
});
expect(progress).toHaveBeenLastCalledWith(
expect.objectContaining({ uploadedBytes: 3 }),
);
},
);
it('preserves media attributes and does not mutate options', async () => {
const { client, calls } = server();
const image = Object.freeze({ subType: 1, summary: 'picture' });
await client.sendGroupImage(123, new Blob(['bytes']), { image });
expect(calls.at(-1)?.params.message).toEqual([
{ type: 'image', data: { file: '/server/staged.bin', subType: 1, summary: 'picture' } },
]);
await client.sendPrivateVideo(456, new Uint8Array([1]), {
video: { thumb: '/server/thumb' },
});
expect(calls.at(-1)?.params.message).toEqual([
{ type: 'video', data: { file: '/server/staged.bin', thumb: '/server/thumb' } },
]);
});
it('keeps the uploaded file authoritative when callers pass wider attribute objects', async () => {
const { client, calls } = server();
// Structurally typed options may include extra fields from an existing segment.
const image = { file: '/wrong/image', summary: 'picture' };
const record = { file: '/wrong/record', url: 'https://example.test/voice' };
const video = { file: '/wrong/video', thumb: '/server/thumb' };
const source = new Uint8Array([1]);
await client.sendGroupImage(123, source, { image });
await client.sendPrivateImage(123, source, { image });
await client.sendGroupRecord(123, source, { record });
await client.sendPrivateRecord(123, source, { record });
await client.sendGroupVideo(123, source, { video });
await client.sendPrivateVideo(123, source, { video });
const sends = calls.filter((call) => call.action.startsWith('send_'));
expect(sends).toHaveLength(6);
for (const send of sends) {
expect(send.params.message).toEqual([
expect.objectContaining({
data: expect.objectContaining({ file: '/server/staged.bin' }),
}),
]);
}
expect(image.file).toBe('/wrong/image');
});
it('preserves File names and uses the existing file publishing actions exactly once', async () => {
const { client, calls } = server();
await expect(
client.sendGroupFile(123, new File(['bytes'], '报告.txt'), {
folderId: '/folder',
}),
).resolves.toEqual({ file_id: 'file-id' });
expect(calls[0].params.filename).toBe('报告.txt');
expect(calls.at(-1)).toEqual({
action: 'upload_group_file',
params: {
group_id: 123,
file: '/server/staged.bin',
name: '报告.txt',
folder_id: '/folder',
upload_file: true,
},
});
await client.sendPrivateFile(
456,
{ source: new Uint8Array([1]), filename: 'original.txt' },
{
filename: 'staged.txt',
name: '发送.txt',
},
);
expect(calls[3].params.filename).toBe('staged.txt');
expect(calls.at(-1)).toEqual({
action: 'upload_private_file',
params: {
user_id: 456,
file: '/server/staged.bin',
name: '发送.txt',
upload_file: true,
},
});
});
it('keeps async sources incremental and accepts declared sizes', async () => {
const { client, calls } = server();
const source = (async function* () {
yield new Uint8Array([1]);
expect(calls).toHaveLength(1);
yield new Uint8Array([2]);
})();
await client.sendGroupImage(123, source, { fileSize: 2, chunkSize: 1 });
expect(calls).toHaveLength(4);
});
it('reports upload failure with its cause and never dispatches the message', async () => {
const { client, calls } = server({ failUpload: true });
await expect(client.sendGroupImage(123, new Uint8Array([1]))).rejects.toMatchObject({
name: 'SnowLumaMediaSendError',
stage: 'upload',
upload: undefined,
cause: expect.any(SnowLumaApiError),
});
expect(calls).toHaveLength(1);
});
it('retains the completed upload and original send error without retrying', async () => {
const { client, calls } = server({ failSend: true });
await expect(client.sendGroupImage(123, new Uint8Array([1]))).rejects.toMatchObject({
stage: 'send',
upload: { file_path: '/server/staged.bin' },
cause: expect.objectContaining({ retcode: 123 }),
});
expect(calls).toHaveLength(3);
});
it('prevents dispatch when cancellation occurs between upload and send', async () => {
const controller = new AbortController();
const { client, calls } = server();
const upload = client.uploadFileStream.bind(client);
vi.spyOn(client, 'uploadFileStream').mockImplementation(async (...args) => {
const result = await upload(...args);
controller.abort('stop');
return result;
});
await expect(
client.sendGroupImage(123, new Uint8Array([1]), {
signal: controller.signal,
}),
).rejects.toMatchObject({ stage: 'send', cause: expect.any(SnowLumaAbortError) });
expect(calls.every((call) => call.action === 'upload_file_stream')).toBe(true);
});
it('passes cancellation to the upload and stops before any later stage', async () => {
const controller = new AbortController();
const { client, calls } = server();
await expect(
client.sendPrivateFile(123, new Uint8Array([1, 2]), {
chunkSize: 1,
signal: controller.signal,
onProgress: () => controller.abort(),
}),
).rejects.toMatchObject({ stage: 'upload', cause: expect.any(SnowLumaAbortError) });
expect(calls).toHaveLength(1);
});
it('passes request controls to both stages', async () => {
const { client } = server();
const upload = vi.spyOn(client, 'uploadFileStream');
const send = vi.spyOn(client, 'sendGroupMessage');
const controls = { signal: new AbortController().signal, timeoutMs: 1234, echo: 'media' };
await client.sendGroupImage(123, new Uint8Array([1]), controls);
expect(upload.mock.calls[0][1]).toMatchObject(controls);
expect(send.mock.calls[0][2]).toEqual(controls);
});
it.each(['abort', 'timeout'] as const)(
'retains upload metadata when an in-flight send ends by %s',
async (mode) => {
vi.useFakeTimers();
try {
const controller = new AbortController();
const { client, calls } = server({
sendResponse: (init) =>
new Promise((_resolve, reject) => {
init?.signal?.addEventListener(
'abort',
() => reject(init.signal?.reason),
{ once: true },
);
}),
});
const result = client.sendGroupImage(123, new Uint8Array([1]), {
signal: controller.signal,
timeoutMs: 1000,
});
const rejected = expect(result).rejects.toMatchObject({
stage: 'send',
upload: { file_path: '/server/staged.bin' },
cause:
mode === 'abort'
? expect.any(SnowLumaAbortError)
: expect.any(SnowLumaTimeoutError),
});
await vi.waitFor(() => expect(calls.at(-1)?.action).toBe('send_group_msg'));
if (mode === 'abort') {
controller.abort();
} else {
await vi.advanceTimersByTimeAsync(1000);
}
await rejected;
expect(calls).toHaveLength(3);
expect(vi.getTimerCount()).toBe(0);
} finally {
vi.useRealTimers();
}
},
);
it('never treats a string as a local path', async () => {
const { client, fetch } = server();
// @ts-expect-error A local path must be explicitly opened with localFile().
await expect(client.sendGroupImage(123, './local.png')).rejects.toBeInstanceOf(
SnowLumaMediaSendError,
);
expect(fetch).not.toHaveBeenCalled();
});
});
describe('Node localFile source', () => {
let directory: string | undefined;
afterEach(async () => {
if (directory) {
await rm(directory, { recursive: true, force: true });
}
});
async function fixture() {
directory = await mkdtemp(join(tmpdir(), 'sdk-local-media-'));
const path = join(directory, '图片.bin');
await writeFile(path, new Uint8Array([7, 8, 9]));
return path;
}
it('sends file-backed bytes from paths and file URLs without leaking the local path', async () => {
const path = await fixture();
for (const location of [path, pathToFileURL(path)]) {
const source = await localFile(location);
const { client, calls } = server();
await client.sendPrivateImage(123, source);
expect(calls[0].params).toMatchObject({
filename: '图片.bin',
file_size: 3,
chunk_data: 'BwgJ',
});
expect(JSON.stringify(calls)).not.toContain(directory);
}
});
it('reports missing files and rejects directories before uploading', async () => {
const path = await fixture();
await expect(localFile(path + '.missing')).rejects.toMatchObject({ code: 'ENOENT' });
await expect(localFile(directory!)).rejects.toThrow('regular file');
});
it('fails if the file changes after the source is created', async () => {
const path = await fixture();
const source = await localFile(path);
await writeFile(path, 'modified file');
const { client, fetch } = server();
await expect(client.sendGroupFile(123, source)).rejects.toMatchObject({ stage: 'upload' });
expect(fetch).not.toHaveBeenCalled();
});
});
+114
View File
@@ -0,0 +1,114 @@
import { describe, expect, it, vi } from 'vitest';
import { atAll, chain, face, fromCQString, message, parseSegments, text, toCQString } from '../src';
describe('MessageChain', () => {
it.each([undefined, true])(
'preserves animation results through builders and CQ with large=%s',
(large) => {
const options = { resultId: '0', ...(large !== undefined ? { large } : {}) };
const expected = [{ type: 'face', data: { id: '114', ...options } }];
expect(face(114, options).toSegments()).toEqual(expected);
expect(chain().face(114, options).toSegments()).toEqual(expected);
expect(message.face(114, options)).toEqual(expected[0]);
expect(parseSegments(toCQString(face(114, options)))).toEqual(expected);
},
);
it.each([false, true])(
'preserves face animation selection through builders and CQ, large=%s',
(large) => {
const expected = [{ type: 'face', data: { id: '451', large } }];
const built = face(451, { large });
expect(built.toSegments()).toEqual(expected);
expect(chain().face(451, { large }).toSegments()).toEqual(expected);
expect(message.face(451, { large })).toEqual(expected[0]);
expect(parseSegments(toCQString(built))).toEqual(expected);
expect(parseSegments(`[CQ:face,id=451,large=${large ? 1 : 0}]`)).toEqual(expected);
},
);
it('rejects an invalid face animation choice instead of discarding it', () => {
expect(() => parseSegments('[CQ:face,id=451,large=invalid]')).toThrow('face.large');
});
it('builds fluent segment chains', () => {
const chain = text('hello').at(10001).br().image('/tmp/a.png');
expect(chain.length).toBe(4);
expect(chain.toSegments()).toEqual([
{ type: 'text', data: { text: 'hello' } },
{ type: 'at', data: { qq: '10001' } },
{ type: 'text', data: { text: '\n' } },
{ type: 'image', data: { file: '/tmp/a.png' } },
]);
});
it('keeps the old segment builder API available', () => {
expect(message.text('hello')).toEqual({ type: 'text', data: { text: 'hello' } });
expect(atAll().toSegments()).toEqual([{ type: 'at', data: { qq: 'all' } }]);
});
it('allows reply only once in a chain at runtime', () => {
const chain = text('hello').reply(1);
expect(() => (chain as any).reply(2)).toThrow(/reply/);
expect(() => chain.append(message.reply(2))).toThrow(/reply/);
});
it('appends other messages and converts to CQ string', () => {
const chain = text('hello').append([message.at('all'), message.face(14)]);
expect(toCQString(chain)).toBe('hello[CQ:at,qq=all][CQ:face,id=14]');
});
it('parses CQ strings into chains', () => {
const chain = fromCQString('hi[CQ:at,qq=all][CQ:image,file=a&#44;b]');
expect(chain.toSegments()).toEqual([
{ type: 'text', data: { text: 'hi' } },
{ type: 'at', data: { qq: 'all' } },
{
type: 'image',
data: { file: 'a,b', url: undefined, type: undefined, summary: undefined },
},
]);
expect(parseSegments('[CQ:reply,id=7]')).toEqual([{ type: 'reply', data: { id: '7' } }]);
});
it.each([
message.image('literal&#44;&amp;#44;.png'),
message.customReply('literal &#44; [CQ:at,qq=all] &[],'),
message.raw('extension', { value: '&#91;&#93;&#44;&amp; &[],' }),
])('decodes CQ parameters exactly once for $type', (segment) => {
expect(parseSegments(toCQString(segment))).toEqual([segment]);
});
it('preserves string custom replies and their metadata through CQ conversion', () => {
const reply = message.customReply('hello[CQ:at,qq=all] & goodbye', {
id: '-123',
qq: '10001',
time: '1700000000',
seq: '42',
});
expect(parseSegments(toCQString(reply))).toEqual([reply]);
const inline = message.customReply('quoted');
expect(parseSegments(toCQString(inline))).toEqual([inline]);
});
it.each([[message.text('quoted')], message.text('quoted')].map((content) => ({ content })))(
'rejects nested custom replies during CQ conversion ($content)',
({ content }) => {
expect(() => toCQString(message.customReply(content))).toThrow(/must be sent as JSON/);
},
);
it('can send itself through a compatible client', async () => {
const client = {
sendGroupMessage: vi.fn().mockResolvedValue({ message_id: 1 }),
sendPrivateMessage: vi.fn().mockResolvedValue({ message_id: 2 }),
};
await text('hi').sendToGroup(client, 123);
await text('yo').sendToPrivate(client, 456);
expect(client.sendGroupMessage).toHaveBeenCalledWith(123, text('hi'), undefined);
expect(client.sendPrivateMessage).toHaveBeenCalledWith(456, text('yo'), undefined);
});
});
+261
View File
@@ -0,0 +1,261 @@
import { spawnSync } from 'node:child_process';
import {
copyFileSync,
cpSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
symlinkSync,
writeFileSync,
} from 'node:fs';
import { createRequire } from 'node:module';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { afterAll, beforeAll, describe, it } from 'vitest';
const sdkRoot = fileURLToPath(new URL('..', import.meta.url));
const compiler = createRequire(import.meta.url).resolve('typescript/bin/tsc');
const manifest = JSON.parse(readFileSync(path.join(sdkRoot, 'package.json'), 'utf8')) as {
name: string;
exports: Record<string, string | { import: string; types: string }>;
};
const entrypoints = Object.entries(manifest.exports)
.filter(([, target]) => typeof target !== 'string')
.map(([entry]) => `${manifest.name}${entry === '.' ? '' : entry.slice(1)}`);
let consumerRoot: string;
function node(args: string[]): void {
const result = spawnSync(process.execPath, args, {
cwd: consumerRoot,
encoding: 'utf8',
stdio: 'pipe',
timeout: 30_000,
});
if (result.error) {
throw result.error;
}
if (result.status !== 0) {
throw new Error(
`Node exited with status ${result.status} (signal ${result.signal}):\n${result.stdout}\n${result.stderr}`,
);
}
}
beforeAll(() => {
consumerRoot = mkdtempSync(path.join(tmpdir(), 'snowluma-sdk-package-'));
const installed = path.join(consumerRoot, 'node_modules', '@snowluma', 'sdk');
mkdirSync(installed, { recursive: true });
copyFileSync(path.join(sdkRoot, 'package.json'), path.join(installed, 'package.json'));
// Install the declared runtime dependency into this isolated consumer. Node
// type declarations belong to the consuming Node project's dev environment.
const require = createRequire(import.meta.url);
cpSync(
path.dirname(require.resolve('ws/package.json')),
path.join(consumerRoot, 'node_modules', 'ws'),
{ recursive: true },
);
mkdirSync(path.join(consumerRoot, 'node_modules', '@types'), { recursive: true });
symlinkSync(
path.dirname(require.resolve('@types/node/package.json')),
path.join(consumerRoot, 'node_modules', '@types', 'node'),
'dir',
);
copyFileSync(
path.join(sdkRoot, 'tests', 'fixtures', 'action-types.mts'),
path.join(consumerRoot, 'action-types.mts'),
);
copyFileSync(
path.join(sdkRoot, 'tests', 'fixtures', 'action-contracts.ts'),
path.join(consumerRoot, 'action-contracts.ts'),
);
copyFileSync(
path.join(sdkRoot, 'tests', 'fixtures', 'messaging-types.mts'),
path.join(consumerRoot, 'messaging-types.mts'),
);
writeFileSync(
path.join(consumerRoot, 'reverse-types.mts'),
`import { text } from '@snowluma/sdk';
import { createReverseWebSocketServer, type SnowLumaReverseWebSocketSession } from '@snowluma/sdk/node';
const reverse = createReverseWebSocketServer({ port: 0, selfIds: ['10001'] });
reverse.on('connection', (bot: SnowLumaReverseWebSocketSession) => {
const id: string = bot.selfId;
const ready: boolean = bot.canCallActions;
void bot.getStatus();
bot.onMessage(async (_event, context) => { await context.reply(text(id)); });
void ready;
});
`,
);
writeFileSync(
path.join(consumerRoot, 'browser-types.mts'),
`
import { SnowLumaWebSocketClient, text } from '@snowluma/sdk';
new SnowLumaWebSocketClient().onMessage(async (_event, context) => { await context.reply(text('hello')); });
// @ts-expect-error Node globals must not leak through the browser entry.
process.cwd();
`,
);
writeFileSync(
path.join(consumerRoot, 'tsconfig.browser.json'),
JSON.stringify({
compilerOptions: {
strict: true,
noEmit: true,
target: 'ES2022',
lib: ['ES2022', 'DOM'],
module: 'ESNext',
moduleResolution: 'Bundler',
types: [],
},
files: ['browser-types.mts'],
}),
);
// Compile fresh output outside the workspace so neither stale dist files nor
// Vitest's source resolver can hide a broken published entry point.
node([
compiler,
'--project',
path.join(sdkRoot, 'tsconfig.json'),
'--outDir',
path.join(installed, 'dist'),
]);
writeFileSync(
path.join(consumerRoot, 'consumer.mts'),
`import { SnowLumaHttpClient, text, type SnowLumaKnownAction as RootKnownAction } from '@snowluma/sdk';
import { SnowLumaWebSocketClient, type SnowLumaHttpClientOptions } from '@snowluma/sdk/client';
import { MessageChain } from '@snowluma/sdk/messages';
import { isMessageEvent, type EventMiddleware } from '@snowluma/sdk/events';
import { SnowLumaApiError } from '@snowluma/sdk/errors';
import { SNOWLUMA_ACTIONS, isSnowLumaAction, type SnowLumaKnownAction } from '@snowluma/sdk/actions';
import type { ActionParams, StatusInfo } from '@snowluma/sdk/types';
const options: SnowLumaHttpClientOptions = { baseUrl: 'http://127.0.0.1:3000/' };
const client = new SnowLumaHttpClient(options);
const status: Promise<StatusInfo> = client.getStatus();
const message: MessageChain = text('hello').reply(-42);
const params: ActionParams<'send_group_msg'> = { group_id: 123, message: [] };
client.raw('send_group_msg', params);
client.raw('consumer_extension', { custom: true });
client.sendGroupMessage(123, message);
const middleware: EventMiddleware = async (event, context, next) => {
if (isMessageEvent(event)) await context.reply(text('received'));
await next();
};
new SnowLumaWebSocketClient().use(middleware);
const action: RootKnownAction = SNOWLUMA_ACTIONS[0];
function known(value: string): SnowLumaKnownAction | null {
return isSnowLumaAction(value) ? value : null;
}
const error = new SnowLumaApiError({ status: 'failed', retcode: 1, data: null });
export { status, action, known, error };
`,
);
}, 30_000);
afterAll(() => {
if (consumerRoot) {
rmSync(consumerRoot, { recursive: true, force: true });
}
});
describe('SDK package entry points', () => {
it('keeps browser consumers independent of Node server declarations', () => {
node([compiler, '-p', path.join(consumerRoot, 'tsconfig.browser.json')]);
}, 30_000);
it('starts the published Node reverse server without workspace resolution', () => {
node([
'--input-type=module',
'--eval',
`
import assert from 'node:assert/strict';
import { createReverseWebSocketServer } from '@snowluma/sdk/node';
const server = createReverseWebSocketServer({ port: 0 });
await server.listen();
assert.ok(server.address.port > 0);
await server.close();
`,
]);
});
it.each(entrypoints)('loads %s in native Node without a bundler', (entrypoint) => {
node(['--input-type=module', '--eval', `await import(${JSON.stringify(entrypoint)});`]);
});
it('preserves runtime identities across the root and subpath exports', () => {
node([
'--input-type=module',
'--eval',
`import assert from 'node:assert/strict';
import * as sdk from '@snowluma/sdk';
import * as client from '@snowluma/sdk/client';
import * as messages from '@snowluma/sdk/messages';
import * as events from '@snowluma/sdk/events';
import * as errors from '@snowluma/sdk/errors';
import * as actions from '@snowluma/sdk/actions';
assert.equal(sdk.SnowLumaHttpClient, client.SnowLumaHttpClient);
assert.equal(sdk.SnowLumaWebSocketClient, client.SnowLumaWebSocketClient);
assert.equal(sdk.MessageChain, messages.MessageChain);
assert.equal(sdk.quoteMessage, messages.quoteMessage);
assert.equal(sdk.isMessageEvent, events.isMessageEvent);
assert.equal(sdk.SnowLumaApiError, errors.SnowLumaApiError);
assert.equal(sdk.SnowLumaMediaSendError, errors.SnowLumaMediaSendError);
assert.equal(sdk.SNOWLUMA_ACTIONS, actions.SNOWLUMA_ACTIONS);
assert.equal(sdk.isSnowLumaAction, actions.isSnowLumaAction);
assert.ok(sdk.text('hello') instanceof messages.MessageChain);
assert.equal(actions.isSnowLumaAction('send_group_msg'), true);
assert.equal(actions.isSnowLumaAction('consumer_extension'), false);
`,
]);
});
it.each([
['NodeNext', 'NodeNext'],
['ESNext', 'Bundler'],
])(
'typechecks a consumer using %s / %s',
(module, resolution) => {
node([
compiler,
'--noEmit',
'--strict',
'--target',
'ES2022',
'--lib',
'ES2022,DOM',
'--module',
module,
'--moduleResolution',
resolution,
path.join(consumerRoot, 'consumer.mts'),
path.join(consumerRoot, 'action-types.mts'),
path.join(consumerRoot, 'messaging-types.mts'),
]);
node([
compiler,
'--noEmit',
'--strict',
'--target',
'ES2022',
'--lib',
'ES2022,DOM',
'--types',
'node',
'--module',
module,
'--moduleResolution',
resolution,
path.join(consumerRoot, 'reverse-types.mts'),
]);
},
30_000,
);
});
+242
View File
@@ -0,0 +1,242 @@
import { describe, expect, it } from 'vitest';
import { SnowLumaApiClient } from '../src/client/api-client';
import { SnowLumaApiError } from '../src/errors';
import { createEventContext } from '../src/events/context';
import { chain } from '../src/messages/chain';
import { quoteMessage } from '../src/messages/quote';
import { segments } from '../src/messages/segments';
import type {
ActionParams,
ActionResult,
ApiResponse,
OneBotGroupMessageEvent,
OneBotPrivateMessageEvent,
OneBotRequestEvent,
RequestOptions,
SnowLumaAction,
} from '../src/types';
class RecordingClient extends SnowLumaApiClient {
readonly calls: Array<{
action: string;
params: unknown;
options: RequestOptions | undefined;
}> = [];
response: ApiResponse = { status: 'ok', retcode: 0, data: { message_id: -19 } };
async request<TAction extends SnowLumaAction>(
action: TAction,
params?: ActionParams<TAction>,
options?: RequestOptions,
): Promise<ApiResponse<ActionResult<TAction>>> {
this.calls.push({ action, params, options });
return this.response as ApiResponse<ActionResult<TAction>>;
}
}
const PRIVATE_EVENT: OneBotPrivateMessageEvent = {
time: 1,
self_id: 10000,
post_type: 'message',
message_type: 'private',
sub_type: 'friend',
message_id: -2147483648,
user_id: 10001,
message: [{ type: 'text', data: { text: 'hello' } }],
raw_message: 'hello',
font: 0,
sender: { user_id: 10001, nickname: 'tester' },
};
const GROUP_EVENT: OneBotGroupMessageEvent = {
...PRIVATE_EVENT,
message_type: 'group',
sub_type: 'normal',
group_id: 20001,
message_id: 2147483647,
};
describe('quoteMessage', () => {
it('preserves CQ strings, extension fields and escaping byte for byte', () => {
const body = 'hi&amp;[CQ:image,file=a&#44;b,subType=7,extension=keep]';
expect(quoteMessage(-123, body)).toBe(`[CQ:reply,id=-123]${body}`);
});
it('treats the complete string as literal text when autoEscape is enabled', () => {
const body = '[CQ:reply,id=7] &#91; &amp;';
expect(quoteMessage(-123, body, { autoEscape: true })).toEqual([
segments.reply(-123),
segments.text(body),
]);
});
it('does not interpret escaped CQ-like text as an existing reply', () => {
const body = '&#91;CQ:reply,id=7&#93;';
expect(quoteMessage(-123, body)).toBe(`[CQ:reply,id=-123]${body}`);
});
it('adds a quote to one segment, an array and a chain without mutating inputs', () => {
const segment = Object.freeze({
type: 'text' as const,
data: Object.freeze({ text: 'hello' }),
});
const array = [segment];
Object.freeze(array);
const messageChain = chain().text('hello');
const expected = [segments.reply(-123), segment];
expect(quoteMessage(-123, segment)).toEqual(expected);
expect(quoteMessage(-123, array)).toEqual(expected);
expect(quoteMessage(-123, messageChain)).toEqual(expected);
expect(array).toEqual([segment]);
expect(messageChain.toSegments()).toEqual([segment]);
});
it.each([
'[CQ:reply,id=-12]hello',
segments.reply(-12),
[segments.text('hello'), segments.reply(-12)],
chain().text('hello').reply(-12),
segments.customReply('quoted'),
])('rejects a body that already contains a reply: %j', (body) => {
expect(() => quoteMessage(-123, body)).toThrow(/containing a reply/);
});
it('does not treat replies inside a forward node as an outer reply', () => {
const node = segments.node(10001, 'tester', [segments.reply(-12), segments.text('body')]);
expect(quoteMessage(-123, node)).toEqual([segments.reply(-123), node]);
});
it.each([0, NaN, Infinity, -Infinity, 1.5, -2147483649, 2147483648])(
'rejects invalid message ID %s',
(messageId) => {
expect(() => quoteMessage(messageId, 'hello')).toThrow(/signed 32-bit message ID/);
},
);
});
describe('event context quote', () => {
it('quotes a private event with its signed ID and forwards request options', async () => {
const client = new RecordingClient();
const context = createEventContext(PRIVATE_EVENT, client);
const options = { signal: new AbortController().signal, timeoutMs: 250, echo: 'quote' };
await expect(context.quote('reply body', options)).resolves.toEqual({ message_id: -19 });
expect(client.calls).toEqual([
{
action: 'send_private_msg',
params: {
user_id: 10001,
message: '[CQ:reply,id=-2147483648]reply body',
auto_escape: undefined,
},
options,
},
]);
});
it('quotes a group event in its group rather than sending privately to its author', async () => {
const client = new RecordingClient();
const context = createEventContext(GROUP_EVENT, client);
await context.quote(chain().text('reply body'));
expect(client.calls).toEqual([
{
action: 'send_group_msg',
params: {
group_id: 20001,
message: [segments.reply(2147483647), segments.text('reply body')],
auto_escape: undefined,
},
options: undefined,
},
]);
});
it('sends literal CQ text with autoEscape without escaping the added quote', async () => {
const client = new RecordingClient();
const context = createEventContext(PRIVATE_EVENT, client);
await context.quote('[CQ:reply,id=-99]hello', { autoEscape: true });
expect(client.calls[0]?.params).toEqual({
user_id: 10001,
message: [segments.reply(-2147483648), segments.text('[CQ:reply,id=-99]hello')],
auto_escape: true,
});
});
it('keeps reply() unquoted and preserves its existing CQ behavior', async () => {
const client = new RecordingClient();
const context = createEventContext(PRIVATE_EVENT, client);
const body = '[CQ:reply,id=7]hello';
await context.reply(body);
expect(client.calls[0]?.params).toEqual({
user_id: 10001,
message: body,
auto_escape: undefined,
});
});
it('does not perform a request when the event is not a message', async () => {
const client = new RecordingClient();
const event: OneBotRequestEvent = {
time: 1,
self_id: 10000,
post_type: 'request',
request_type: 'friend',
user_id: 10001,
flag: 'request-1',
};
await expect(createEventContext(event, client).quote('hello')).rejects.toThrow(
'quote() is only available for message events',
);
expect(client.calls).toHaveLength(0);
});
it('does not send duplicate quotes or invalid event IDs', async () => {
const client = new RecordingClient();
await expect(
createEventContext(PRIVATE_EVENT, client).quote('[CQ:reply,id=7]'),
).rejects.toThrow(/containing a reply/);
await expect(
createEventContext({ ...PRIVATE_EVENT, message_id: 0 }, client).quote('hello'),
).rejects.toThrow(/signed 32-bit message ID/);
expect(client.calls).toHaveLength(0);
});
it('preserves the original transport failure', async () => {
const client = new RecordingClient();
const failure = new Error('connection lost');
client.request = async () => {
throw failure;
};
await expect(createEventContext(PRIVATE_EVENT, client).quote('hello')).rejects.toBe(
failure,
);
});
it('preserves API failure details without retrying the send', async () => {
const client = new RecordingClient();
client.response = {
status: 'failed',
retcode: 1200,
data: null,
wording: 'quote unavailable',
};
await expect(
createEventContext(PRIVATE_EVENT, client).quote('hello'),
).rejects.toMatchObject({
constructor: SnowLumaApiError,
retcode: 1200,
wording: 'quote unavailable',
});
expect(client.calls).toHaveLength(1);
});
});
+103
View File
@@ -0,0 +1,103 @@
import { describe, expect, expectTypeOf, it, vi } from 'vitest';
import {
SnowLumaHttpClient,
type JsonObject,
type QzonePublicFeedsPage,
type QzoneShareArkResult,
} from '../src';
const feed: JsonObject = {
cell_common: {
domain_type: 3,
union_id: { uf_key: '9007199254740993', unknown_identity: 'preserved' },
},
cell_feed_transfer: { opaque: 'fixture' },
unknown_cell: { decimal: '18446744073709551615' },
};
describe('public Qzone raw actions', () => {
it('exposes homepage and card types without automatically sending the card', async () => {
const page: QzonePublicFeedsPage = { feeds: [feed], has_more: false, next_cursor: null };
const card: QzoneShareArkResult = {
ark: '{ "app": "com.tencent.tuwen.lua" }',
requires_confirmation: true,
notice: '所有人可见',
};
const fetchImpl = vi
.fn()
.mockResolvedValueOnce(
new Response(JSON.stringify({ status: 'ok', retcode: 0, data: page })),
)
.mockResolvedValueOnce(
new Response(JSON.stringify({ status: 'ok', retcode: 0, data: card })),
);
const client = new SnowLumaHttpClient({ fetch: fetchImpl });
const homepage = await client.raw('get_qzone_home_feeds', {});
expectTypeOf(homepage).toEqualTypeOf<QzonePublicFeedsPage>();
const result = await client.raw('get_qzone_share_ark', { feed: homepage.feeds[0]! });
expectTypeOf(result).toEqualTypeOf<QzoneShareArkResult>();
expect(result).toEqual(card);
expect(fetchImpl).toHaveBeenCalledTimes(2);
expect(JSON.parse(fetchImpl.mock.calls[1]![1].body)).toEqual({
action: 'get_qzone_share_ark',
params: { feed },
});
});
it('preserves original feed cells and follows an explicit cursor after a short page', async () => {
const first: QzonePublicFeedsPage = {
feeds: [feed],
has_more: true,
next_cursor: 'account-bound-cursor',
upstream_page: { untouched: '18446744073709551615' },
};
const last: QzonePublicFeedsPage = { feeds: [], has_more: false, next_cursor: null };
const fetchImpl = vi
.fn()
.mockImplementationOnce(
async () => new Response(JSON.stringify({ status: 'ok', retcode: 0, data: first })),
)
.mockImplementationOnce(
async () => new Response(JSON.stringify({ status: 'ok', retcode: 0, data: last })),
);
const client = new SnowLumaHttpClient({ fetch: fetchImpl });
const page = await client.raw('get_qzone_public_feeds', {});
expectTypeOf(page).toEqualTypeOf<QzonePublicFeedsPage>();
expect(page).toEqual(first);
expect(fetchImpl).toHaveBeenCalledTimes(1);
expect(page.has_more).toBe(true);
expect(await client.raw('get_qzone_public_feeds', { cursor: page.next_cursor! })).toEqual(
last,
);
expect(
fetchImpl.mock.calls.map((call) => JSON.parse((call[1] as RequestInit).body as string)),
).toEqual([
{ action: 'get_qzone_public_feeds', params: {} },
{ action: 'get_qzone_public_feeds', params: { cursor: 'account-bound-cursor' } },
]);
});
it('preserves complete comment and reply objects and unknown writer results', async () => {
const comment: JsonObject = { id: '9007199254740993', unknown_parent: { keep: true } };
const reply: JsonObject = { id: '18446744073709551615', user: { uid: 'reader' } };
const content = [{ type: 0, content: 'Reply', upstream_option: true }];
const data = { reply, upstream_result: { untouched: true } };
const fetchImpl = vi
.fn()
.mockImplementation(
async () => new Response(JSON.stringify({ status: 'ok', retcode: 0, data })),
);
const client = new SnowLumaHttpClient({ fetch: fetchImpl });
const params = { feed, comment, reply, content };
const result = await client.raw('reply_qzone_public_feed', params);
expectTypeOf(result).toEqualTypeOf<JsonObject>();
expect(result).toEqual(data);
expect(fetchImpl).toHaveBeenCalledWith(
'http://127.0.0.1:3000/',
expect.objectContaining({
body: JSON.stringify({ action: 'reply_qzone_public_feed', params }),
}),
);
});
});
@@ -0,0 +1,331 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { once } from 'node:events';
import { createServer } from 'node:http';
import type { AddressInfo } from 'node:net';
import { WebSocket } from 'ws';
import { SnowLumaConnectionError } from '../src/errors.js';
import {
createReverseWebSocketServer,
type ReverseWebSocketServerOptions,
type SnowLumaReverseWebSocketServer,
type SnowLumaReverseWebSocketSession,
} from '../src/node/index.js';
const servers: SnowLumaReverseWebSocketServer[] = [];
const peers: WebSocket[] = [];
afterEach(async () => {
peers.splice(0).forEach((peer) => peer.terminate());
await Promise.all(servers.splice(0).map((server) => server.close()));
});
async function start(options: ReverseWebSocketServerOptions = {}) {
const server = createReverseWebSocketServer({ port: 0, accessToken: 'test-token', ...options });
const errors = vi.fn();
server.on('error', errors);
servers.push(server);
await server.listen();
const port = (server.address as AddressInfo).port;
const connect = (
id = '10001',
role = 'Universal',
extras: { path?: string; headers?: Record<string, string>; autoPong?: boolean } = {},
) => {
const peer = new WebSocket(`ws://127.0.0.1:${port}${extras.path ?? '/onebot/v11/ws'}`, {
headers: {
'X-Self-ID': id,
'X-Client-Role': role,
Authorization: 'Bearer test-token',
...extras.headers,
},
autoPong: extras.autoPong,
});
peers.push(peer);
return peer;
};
const accept = async (id = '10001', role = 'Universal') => {
const peer = connect(id, role);
await once(peer, 'open');
const client = server.getClient(id)!;
expect(client).toBeDefined();
return { peer, client };
};
return { server, errors, connect, accept };
}
const event = (selfId = 10001) => ({
self_id: selfId,
time: 1,
post_type: 'message',
message_type: 'private',
sub_type: 'friend',
user_id: 20001,
message_id: -42,
message: [{ type: 'text', data: { text: 'hello' } }],
raw_message: 'hello',
font: 0,
sender: { user_id: 20001, nickname: 'Test' },
});
describe('SDK reverse WebSocket server', () => {
it('receives real events and sends actions back over the accepted connection', async () => {
const { server, accept, errors } = await start();
const connected: SnowLumaReverseWebSocketSession[] = [];
server.on('connection', (client) => connected.push(client));
const { peer, client } = await accept();
expect(connected).toEqual([client]);
expect(client.canCallActions).toBe(true);
expect(client.canReceiveEvents).toBe(true);
const received = new Promise<void>((resolve) =>
client.onMessage(async (message, context) => {
expect(message.message_id).toBe(-42);
await context.reply('reply');
resolve();
}),
);
const called = once(peer, 'message');
peer.send(JSON.stringify(event()));
const action = JSON.parse((await called)[0].toString());
expect(action).toMatchObject({ action: 'send_private_msg', params: { user_id: 20001 } });
peer.send(
JSON.stringify({
status: 'ok',
retcode: 0,
data: { message_id: -43 },
echo: action.echo,
}),
);
await received;
expect(errors).not.toHaveBeenCalled();
});
it('accepts both trailing-slash forms of the configured path', async () => {
const { accept } = await start({ path: '/onebot/v11/ws/' });
expect((await accept()).client.isConnected).toBe(true);
});
it('isolates equal request echoes between two accounts', async () => {
const { accept } = await start();
const first = await accept('10001');
const second = await accept('10002');
const a = first.client.request('get_login_info', {}, { echo: 'same' });
const b = second.client.request('get_login_info', {}, { echo: 'same' });
await Promise.all([once(first.peer, 'message'), once(second.peer, 'message')]);
second.peer.send(
JSON.stringify({ status: 'ok', retcode: 0, data: { user_id: 10002 }, echo: 'same' }),
);
first.peer.send(
JSON.stringify({ status: 'ok', retcode: 0, data: { user_id: 10001 }, echo: 'same' }),
);
expect((await a).data).toMatchObject({ user_id: 10001 });
expect((await b).data).toMatchObject({ user_id: 10002 });
});
it('reports asynchronous handler failures and supports request cancellation and timeout', async () => {
const { accept, errors } = await start();
const { peer, client } = await accept();
client.onMessage(async () => {
throw new Error('handler failed');
});
peer.send(JSON.stringify(event()));
await vi.waitFor(() =>
expect(errors).toHaveBeenCalledWith(
expect.objectContaining({
cause: expect.objectContaining({ message: 'handler failed' }),
}),
),
);
const abort = new AbortController();
const called = once(peer, 'message');
const cancelled = expect(
client.request('get_status', {}, { signal: abort.signal }),
).rejects.toMatchObject({ name: 'SnowLumaAbortError' });
await called;
abort.abort();
await cancelled;
await expect(client.request('get_status', {}, { timeoutMs: 10 })).rejects.toMatchObject({
name: 'SnowLumaTimeoutError',
});
expect(client.isConnected).toBe(true);
});
it('authenticates and validates identity, role, path and duplicate channels before upgrading', async () => {
const { connect, accept, server } = await start({ selfIds: ['10001'] });
const failed = async (peer: WebSocket, status: number) => {
const [error] = await once(peer, 'error');
expect(error.message).toContain(String(status));
};
await failed(
connect('10001', 'Universal', { headers: { Authorization: 'Bearer wrong' } }),
401,
);
await failed(connect('10002'), 403);
await failed(connect('', 'Universal'), 400);
await failed(connect('10001', 'unknown'), 400);
await failed(connect('10001', 'Universal', { path: '/other' }), 404);
const first = await accept();
await failed(connect(), 409);
expect(server.getClient('10001')).toBe(first.client);
expect(first.client.isConnected).toBe(true);
});
it('combines separate Event/API channels and routes event replies over the API channel', async () => {
const { accept, server } = await start();
const eventPeer = await accept('10001', 'Event');
expect(eventPeer.client.canCallActions).toBe(false);
await expect(eventPeer.client.getStatus()).rejects.toBeInstanceOf(SnowLumaConnectionError);
const apiPeer = await accept('10001', 'API');
expect(apiPeer.client).toBe(eventPeer.client);
expect(apiPeer.client.canCallActions).toBe(true);
const done = new Promise<void>((resolve) =>
apiPeer.client.onMessage(async (_message, context) => {
await context.reply('split-channel reply');
resolve();
}),
);
const called = once(apiPeer.peer, 'message');
eventPeer.peer.send(JSON.stringify(event()));
const request = JSON.parse((await called)[0].toString());
apiPeer.peer.send(
JSON.stringify({
status: 'ok',
retcode: 0,
data: { message_id: 1 },
echo: request.echo,
}),
);
await done;
const eventClosed = once(eventPeer.peer, 'close');
apiPeer.peer.terminate();
await eventClosed;
expect(server.clients.size).toBe(0);
});
it('rejects pending actions and waits on disconnect, without replaying them after reconnect', async () => {
const { accept, server } = await start();
const first = await accept();
const called = once(first.peer, 'message');
const pending = expect(first.client.getStatus()).rejects.toBeInstanceOf(
SnowLumaConnectionError,
);
const waiting = expect(first.client.waitForMessage()).rejects.toBeInstanceOf(
SnowLumaConnectionError,
);
await called;
first.peer.terminate();
await Promise.all([pending, waiting]);
await vi.waitFor(() => expect(server.clients.size).toBe(0));
const replacement = await accept();
expect(replacement.client).not.toBe(first.client);
await expect(first.client.getStatus()).rejects.toThrow('wait for a new server connection');
const seen = vi.fn();
replacement.peer.on('message', seen);
await new Promise((resolve) => setImmediate(resolve));
expect(seen).not.toHaveBeenCalled();
});
it.each(['invalid-json', 'wrong-account', 'wrong-role'])(
'rejects %s frames and exposes the error without dispatching a false event',
async (kind) => {
const { accept, errors } = await start();
const { peer, client } = await accept(
'10001',
kind === 'wrong-role' ? 'Api' : 'Universal',
);
const seen = vi.fn();
client.onMessage(seen);
const closed = once(peer, 'close');
peer.send(
kind === 'invalid-json'
? '{'
: JSON.stringify(event(kind === 'wrong-account' ? 10002 : 10001)),
);
expect((await closed)[0]).toBe(1008);
expect(seen).not.toHaveBeenCalled();
expect(errors).toHaveBeenCalledOnce();
},
);
it('enforces inbound and outbound byte limits and connection capacity', async () => {
const { accept, connect } = await start({
maxPayloadBytes: 256,
maxBufferedBytes: 32,
maxConnections: 1,
});
const { peer, client } = await accept();
const [rejected] = await once(connect('10002'), 'error');
expect(rejected.message).toContain('503');
await expect(client.getStatus()).rejects.toMatchObject({
cause: { message: 'Reverse WebSocket outbound buffer limit exceeded' },
});
const closed = once(peer, 'close');
peer.send('x'.repeat(257));
expect((await closed)[0]).toBe(1009);
});
it('reaps a silent peer and allows a new connection after the old session closes', async () => {
const { connect, accept, server } = await start({ heartbeatIntervalMs: 15 });
const silent = connect('10001', 'Universal', { autoPong: false });
await once(silent, 'open');
await once(silent, 'close');
await vi.waitFor(() => expect(server.clients.size).toBe(0));
expect((await accept()).client.isConnected).toBe(true);
});
it('leaves an externally owned HTTP server running after closing SDK connections', async () => {
const http = createServer((_req, res) => res.end('still serving'));
http.listen(0, '127.0.0.1');
await once(http, 'listening');
const server = createReverseWebSocketServer({ server: http, accessToken: 'token' });
servers.push(server);
server.on('error', (error) => {
throw error;
});
try {
await server.listen();
const port = (http.address() as AddressInfo).port;
const peer = new WebSocket(`ws://127.0.0.1:${port}/onebot/v11/ws?access_token=token`, {
headers: { 'X-Self-ID': '10001', 'X-Client-Role': 'Universal' },
});
peers.push(peer);
await once(peer, 'open');
await server.close();
expect(await (await fetch(`http://127.0.0.1:${port}/health`)).text()).toBe(
'still serving',
);
expect(server.clients.size).toBe(0);
} finally {
await server.close();
await new Promise<void>((resolve, reject) =>
http.close((error) => (error ? reject(error) : resolve())),
);
}
});
it('rejects a failed bind, then supports retry and repeated close', async () => {
const occupied = createServer();
occupied.listen(0, '127.0.0.1');
await once(occupied, 'listening');
const server = createReverseWebSocketServer({
port: (occupied.address() as AddressInfo).port,
});
servers.push(server);
try {
await expect(server.listen()).rejects.toMatchObject({ code: 'EADDRINUSE' });
} finally {
await new Promise<void>((resolve) => occupied.close(() => resolve()));
}
await server.listen();
await Promise.all([server.close(), server.close()]);
await server.listen();
await server.close();
});
});
+374
View File
@@ -0,0 +1,374 @@
import { describe, expect, it } from 'vitest';
import { segments } from '../src/messages/segments';
describe('segments.text', () => {
it('wraps a string as a text segment', () => {
expect(segments.text('hello')).toEqual({ type: 'text', data: { text: 'hello' } });
});
it('keeps empty text', () => {
expect(segments.text('')).toEqual({ type: 'text', data: { text: '' } });
});
});
describe('segments.face', () => {
it('stringifies a numeric face id', () => {
expect(segments.face(14)).toEqual({ type: 'face', data: { id: '14' } });
});
it('keeps a string face id', () => {
expect(segments.face('178')).toEqual({ type: 'face', data: { id: '178' } });
});
it('stringifies zero', () => {
expect(segments.face(0)).toEqual({ type: 'face', data: { id: '0' } });
});
});
describe('segments.at', () => {
it('stringifies a numeric qq and omits empty options', () => {
expect(segments.at(10001)).toEqual({ type: 'at', data: { qq: '10001' } });
});
it('supports at-all', () => {
expect(segments.at('all')).toEqual({ type: 'at', data: { qq: 'all' } });
});
it('includes name when provided', () => {
expect(segments.at(10001, { name: 'luma' })).toEqual({
type: 'at',
data: { qq: '10001', name: 'luma' },
});
});
it('includes uid when provided', () => {
expect(segments.at(10001, { uid: 'u_abc' })).toEqual({
type: 'at',
data: { qq: '10001', uid: 'u_abc' },
});
});
it('includes both name and uid', () => {
expect(segments.at(10001, { name: 'luma', uid: 'u_abc' })).toEqual({
type: 'at',
data: { qq: '10001', name: 'luma', uid: 'u_abc' },
});
});
it('omits empty name and uid', () => {
expect(segments.at(10001, { name: '', uid: '' })).toEqual({
type: 'at',
data: { qq: '10001' },
});
});
});
describe('segments.reply', () => {
it('stringifies a numeric message id', () => {
expect(segments.reply(7)).toEqual({ type: 'reply', data: { id: '7' } });
});
it('keeps a string message id', () => {
expect(segments.reply('abc')).toEqual({ type: 'reply', data: { id: 'abc' } });
});
});
describe('segments.customReply', () => {
it('builds an inline quote without requiring a cached message id', () => {
expect(
segments.customReply([segments.text('SnowLuma')], {
qq: 10001,
time: 1700000000,
seq: 42,
}),
).toEqual({
type: 'reply',
data: {
message: [{ type: 'text', data: { text: 'SnowLuma' } }],
qq: 10001,
time: 1700000000,
seq: 42,
},
});
});
it('accepts a plain quote string without metadata', () => {
expect(segments.customReply('quoted')).toEqual({
type: 'reply',
data: { message: 'quoted' },
});
});
});
describe('segments.image', () => {
it('uses only the file when no options are given', () => {
expect(segments.image('/tmp/a.png')).toEqual({
type: 'image',
data: { file: '/tmp/a.png' },
});
});
it('spreads image options after file', () => {
expect(
segments.image('base64://abc', {
type: 'flash',
subType: 1,
summary: 'pic',
url: 'https://example.com/a.png',
}),
).toEqual({
type: 'image',
data: {
file: 'base64://abc',
type: 'flash',
subType: 1,
summary: 'pic',
url: 'https://example.com/a.png',
},
});
});
});
describe('segments.record', () => {
it('uses only the file when no options are given', () => {
expect(segments.record('/tmp/a.amr')).toEqual({
type: 'record',
data: { file: '/tmp/a.amr' },
});
});
it('spreads record options after file', () => {
expect(segments.record('/tmp/a.amr', { url: 'https://example.com/a.amr' })).toEqual({
type: 'record',
data: { file: '/tmp/a.amr', url: 'https://example.com/a.amr' },
});
});
});
describe('segments.video', () => {
it('uses only the file when no options are given', () => {
expect(segments.video('/tmp/a.mp4')).toEqual({
type: 'video',
data: { file: '/tmp/a.mp4' },
});
});
it('spreads video options after file', () => {
expect(
segments.video('/tmp/a.mp4', {
url: 'https://example.com/a.mp4',
thumb: '/tmp/thumb.jpg',
}),
).toEqual({
type: 'video',
data: {
file: '/tmp/a.mp4',
url: 'https://example.com/a.mp4',
thumb: '/tmp/thumb.jpg',
},
});
});
});
describe('segments.json', () => {
it('keeps a JSON string as-is', () => {
expect(segments.json('{"app":"com.tencent.miniapp"}')).toEqual({
type: 'json',
data: { data: '{"app":"com.tencent.miniapp"}' },
});
});
it('stringifies a JSON object', () => {
expect(segments.json({ app: 'com.tencent.miniapp', desc: 'hi' })).toEqual({
type: 'json',
data: { data: '{"app":"com.tencent.miniapp","desc":"hi"}' },
});
});
});
describe('segments.xml', () => {
it('uses only the xml payload when no options are given', () => {
expect(segments.xml('<msg />')).toEqual({
type: 'xml',
data: { data: '<msg />' },
});
});
it('spreads xml options after data', () => {
expect(segments.xml('<msg />', { id: '12345' })).toEqual({
type: 'xml',
data: { data: '<msg />', id: '12345' },
});
});
});
describe('segments.poke', () => {
it('omits id when it is not provided', () => {
expect(segments.poke(1)).toEqual({ type: 'poke', data: { type: 1 } });
});
it('includes a numeric id', () => {
expect(segments.poke(1, -1)).toEqual({ type: 'poke', data: { type: 1, id: -1 } });
});
it('keeps string type and id', () => {
expect(segments.poke('1', '0')).toEqual({ type: 'poke', data: { type: '1', id: '0' } });
});
it('includes an id of 0', () => {
expect(segments.poke(1, 0)).toEqual({ type: 'poke', data: { type: 1, id: 0 } });
});
});
describe('segments.forward', () => {
it('mirrors the id onto res_id and forward_id', () => {
expect(segments.forward('res-9')).toEqual({
type: 'forward',
data: { id: 'res-9', res_id: 'res-9', forward_id: 'res-9' },
});
});
});
describe('segments.node', () => {
it('stores user_id, nickname, and string content', () => {
expect(segments.node(10001, 'luma', 'hello')).toEqual({
type: 'node',
data: { user_id: 10001, nickname: 'luma', content: 'hello' },
});
});
it('stores a single segment as content', () => {
expect(segments.node(10001, 'luma', { type: 'text', data: { text: 'hi' } })).toEqual({
type: 'node',
data: {
user_id: 10001,
nickname: 'luma',
content: { type: 'text', data: { text: 'hi' } },
},
});
});
it('stores a segment array as content', () => {
expect(segments.node(10001, 'luma', [{ type: 'text', data: { text: 'hi' } }])).toEqual({
type: 'node',
data: {
user_id: 10001,
nickname: 'luma',
content: [{ type: 'text', data: { text: 'hi' } }],
},
});
});
});
describe('segments.share', () => {
it('passes required share fields through', () => {
expect(segments.share({ url: 'https://example.com', title: 'Example' })).toEqual({
type: 'share',
data: { url: 'https://example.com', title: 'Example' },
});
});
it('passes optional share fields through', () => {
expect(
segments.share({
url: 'https://example.com',
title: 'Example',
content: 'desc',
image: 'https://example.com/a.png',
}),
).toEqual({
type: 'share',
data: {
url: 'https://example.com',
title: 'Example',
content: 'desc',
image: 'https://example.com/a.png',
},
});
});
});
describe('segments.music', () => {
it('passes platform music id data through', () => {
expect(segments.music({ type: '163', id: '12345' })).toEqual({
type: 'music',
data: { type: '163', id: '12345' },
});
});
it('passes custom music data through', () => {
expect(
segments.music({
type: 'custom',
url: 'https://example.com/song',
audio: 'https://example.com/song.mp3',
title: 'Song',
image: 'https://example.com/cover.jpg',
content: 'Artist',
}),
).toEqual({
type: 'music',
data: {
type: 'custom',
url: 'https://example.com/song',
audio: 'https://example.com/song.mp3',
title: 'Song',
image: 'https://example.com/cover.jpg',
content: 'Artist',
},
});
});
});
describe('segments.location', () => {
it('passes numeric coordinates and labels through', () => {
expect(
segments.location({
lat: 39.9,
lon: 116.4,
title: 'Beijing',
content: 'Tiananmen',
}),
).toEqual({
type: 'location',
data: {
lat: 39.9,
lon: 116.4,
title: 'Beijing',
content: 'Tiananmen',
},
});
});
it('passes string coordinates through', () => {
expect(segments.location({ lat: '31.2', lon: '121.5' })).toEqual({
type: 'location',
data: { lat: '31.2', lon: '121.5' },
});
});
});
describe('segments.contact', () => {
it('builds a qq contact with a numeric id', () => {
expect(segments.contact('qq', 10001)).toEqual({
type: 'contact',
data: { type: 'qq', id: 10001 },
});
});
it('builds a group contact with a string id', () => {
expect(segments.contact('group', '123456')).toEqual({
type: 'contact',
data: { type: 'group', id: '123456' },
});
});
});
describe('segments.raw', () => {
it('builds an arbitrary typed segment', () => {
expect(segments.raw('dice', { result: 6 })).toEqual({
type: 'dice',
data: { result: 6 },
});
});
});
+749
View File
@@ -0,0 +1,749 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
SnowLumaAbortError,
SnowLumaApiError,
SnowLumaConnectionError,
SnowLumaParseError,
SnowLumaTimeoutError,
SnowLumaWebSocketClient,
type OneBotPrivateMessageEvent,
} from '../src';
class MockWebSocket {
static instances: MockWebSocket[] = [];
readyState = 0;
sent: string[] = [];
readonly listeners = new Map<string, Set<(...args: unknown[]) => void>>();
constructor(
readonly url: string,
readonly protocols?: string | string[],
) {
MockWebSocket.instances.push(this);
}
addEventListener(event: string, listener: (...args: unknown[]) => void): void {
let listeners = this.listeners.get(event);
if (!listeners) {
listeners = new Set();
this.listeners.set(event, listeners);
}
listeners.add(listener);
}
removeEventListener(event: string, listener: (...args: unknown[]) => void): void {
this.listeners.get(event)?.delete(listener);
}
send(data: string): void {
this.sent.push(data);
}
close(code?: number, reason?: string): void {
this.readyState = 3;
this.emit('close', { code, reason });
}
open(): void {
this.readyState = 1;
this.emit('open');
}
message(data: unknown): void {
this.emit('message', { data });
}
emit(event: string, ...args: unknown[]): void {
for (const listener of this.listeners.get(event) ?? []) {
listener(...args);
}
}
}
const PRIVATE_EVENT: OneBotPrivateMessageEvent = {
time: 1,
self_id: 10000,
post_type: 'message',
message_type: 'private',
sub_type: 'friend',
message_id: 7,
message_seq: 8,
user_id: 10001,
message: [{ type: 'text', data: { text: '/ping a b' } }],
raw_message: '/ping a b',
font: 0,
sender: { user_id: 10001, nickname: 'tester' },
};
describe('WebSocket streaming', () => {
afterEach(() => vi.useRealTimers());
async function connected() {
const client = new SnowLumaWebSocketClient({
webSocket: MockWebSocket,
requestTimeoutMs: 1000,
});
const opening = client.connect();
const socket = MockWebSocket.instances.at(-1)!;
socket.open();
await opening;
return { client, socket };
}
const data = { type: 'stream', data_type: 'data_chunk', data: 'part' };
const complete = { type: 'response', data_type: 'data_complete', data: 'done' };
const frame = (echo: string, payload: unknown) =>
JSON.stringify({
status: 'ok',
retcode: 0,
stream: 'stream-action',
echo,
data: payload,
});
it('routes interleaved streams, normal responses and events independently', async () => {
const { client, socket } = await connected();
const event = vi.fn();
client.onEvent(event);
const first = client.stream('test_download_stream', {}, { echo: 'a' });
const second = client.stream('test_download_stream', {}, { echo: 'b' });
const a = first.next();
const b = second.next();
const status = client.getStatus({ echo: 'ordinary' });
await vi.waitFor(() => expect(socket.sent).toHaveLength(3));
socket.message(frame('b', data));
socket.message(frame('a', data));
socket.message(JSON.stringify(PRIVATE_EVENT));
socket.message(
JSON.stringify({ status: 'ok', retcode: 0, echo: 'ordinary', data: { online: true } }),
);
await expect(status).resolves.toEqual({ online: true });
expect((await a).value).toEqual(data);
expect((await b).value).toEqual(data);
socket.message(frame('a', complete));
socket.message(frame('b', complete));
expect((await first.next()).value).toEqual(complete);
expect((await second.next()).value).toEqual(complete);
expect((await first.next()).done).toBe(true);
expect((await second.next()).done).toBe(true);
await vi.waitFor(() => expect(event).toHaveBeenCalledOnce());
client.close();
});
it('shares echo collision checks with single-response calls and preserves later requests', async () => {
const { client, socket } = await connected();
const stream = client.stream('test_download_stream', {}, { echo: 'same' });
const first = stream.next();
await vi.waitFor(() => expect(socket.sent).toHaveLength(1));
await expect(client.getStatus({ echo: 'same' })).rejects.toThrow('already pending');
await expect(
client.stream('test_download_stream', {}, { echo: 'same' }).next(),
).rejects.toThrow('already pending');
socket.message(frame('same', data));
await first;
socket.message(frame('same', complete));
const ordinary = client.getStatus({ echo: 'same' });
await vi.waitFor(() => expect(socket.sent).toHaveLength(2));
await stream.return();
socket.message(
JSON.stringify({ status: 'ok', retcode: 0, data: { online: true }, echo: 'same' }),
);
await expect(ordinary).resolves.toEqual({ online: true });
client.close();
});
it.each([
'abort',
'timeout',
'close',
'invalid frame',
'invalid JSON',
'send failure',
'return',
'complete',
] as const)('releases stream timers and listeners after %s', async (outcome) => {
vi.useFakeTimers();
const { client, socket } = await connected();
const controller = new AbortController();
const remove = vi.spyOn(controller.signal, 'removeEventListener');
if (outcome === 'send failure') {
vi.spyOn(socket, 'send').mockImplementationOnce(() => {
throw new Error('send');
});
}
const stream = client.stream(
'test_download_stream',
{},
{ echo: 's', signal: controller.signal },
);
const next = stream.next();
const settled = next.then(
() => undefined,
(error: unknown) => error,
);
await vi.advanceTimersByTimeAsync(0);
switch (outcome) {
case 'abort':
controller.abort();
break;
case 'timeout':
await vi.advanceTimersByTimeAsync(1000);
break;
case 'close':
socket.close();
break;
case 'invalid frame':
socket.message(JSON.stringify({ echo: 's' }));
break;
case 'invalid JSON':
socket.message('bad json');
break;
case 'return':
socket.message(frame('s', data));
await next;
await stream.return();
break;
case 'complete':
socket.message(frame('s', complete));
await next;
await stream.next();
break;
}
const error = await settled;
const expected = {
abort: SnowLumaAbortError,
timeout: SnowLumaTimeoutError,
close: SnowLumaConnectionError,
'send failure': SnowLumaConnectionError,
'invalid frame': SnowLumaParseError,
'invalid JSON': SnowLumaParseError,
};
if (outcome in expected) {
expect(error).toBeInstanceOf(expected[outcome as keyof typeof expected]);
} else {
expect(error).toBeUndefined();
}
expect(vi.getTimerCount()).toBe(0);
expect(remove).toHaveBeenCalled();
client.close();
});
it.each(['frames', 'bytes'] as const)(
'fails a slow consumer when queued %s exceed its limit',
async (limit) => {
const { client, socket } = await connected();
const stream = client.stream(
'test_download_stream',
{},
{
echo: 's',
maxBufferedFrames: limit === 'frames' ? 1 : 256,
maxBufferBytes: limit === 'bytes' ? 180 : 10000,
},
);
const next = stream.next();
await vi.waitFor(() => expect(socket.sent).toHaveLength(1));
socket.message(frame('s', data));
await next;
socket.message(frame('s', data));
socket.message(frame('s', data));
await expect(stream.next()).rejects.toThrow('buffer limit');
client.close();
},
);
it('throws a terminal API error after previously delivered frames', async () => {
const { client, socket } = await connected();
const stream = client.stream('test_download_stream', {}, { echo: 's' });
const next = stream.next();
await vi.waitFor(() => expect(socket.sent).toHaveLength(1));
socket.message(frame('s', data));
await next;
socket.message(
JSON.stringify({
status: 'failed',
retcode: 1500,
echo: 's',
data: { type: 'error' },
wording: 'failed',
}),
);
await expect(stream.next()).rejects.toBeInstanceOf(SnowLumaApiError);
client.close();
});
it('times out while opening without sending a request later', async () => {
vi.useFakeTimers();
const client = new SnowLumaWebSocketClient({ webSocket: MockWebSocket });
const result = expect(
client.stream('test_download_stream', {}, { timeoutMs: 20 }).next(),
).rejects.toBeInstanceOf(SnowLumaTimeoutError);
const socket = MockWebSocket.instances.at(-1)!;
await vi.advanceTimersByTimeAsync(20);
await result;
socket.open();
expect(socket.sent).toHaveLength(0);
expect(vi.getTimerCount()).toBe(0);
client.close();
});
});
describe('SnowLumaWebSocketClient', () => {
afterEach(() => {
vi.useRealTimers();
});
it('times out while opening and never sends the expired action after a late open', async () => {
vi.useFakeTimers();
const client = new SnowLumaWebSocketClient({ webSocket: MockWebSocket });
const settled = vi.fn();
const request = client.getStatus({ timeoutMs: 20 }).then(settled, settled);
const socket = MockWebSocket.instances.at(-1)!;
try {
await vi.advanceTimersByTimeAsync(20);
expect(settled).toHaveBeenCalledExactlyOnceWith(expect.any(SnowLumaTimeoutError));
expect(settled.mock.calls[0]![0]).toHaveProperty('timeoutMs', 20);
socket.open();
await vi.advanceTimersByTimeAsync(0);
expect(socket.sent).toHaveLength(0);
expect(client.isConnected).toBe(true);
expect(vi.getTimerCount()).toBe(0);
} finally {
client.close();
await request;
}
});
it('uses the client default as one deadline across connection and response waits', async () => {
vi.useFakeTimers();
const client = new SnowLumaWebSocketClient({
webSocket: MockWebSocket,
requestTimeoutMs: 30,
});
const settled = vi.fn();
const request = client.getStatus({ echo: 'deadline' }).then(settled, settled);
const socket = MockWebSocket.instances.at(-1)!;
try {
await vi.advanceTimersByTimeAsync(20);
socket.open();
await vi.advanceTimersByTimeAsync(0);
expect(socket.sent).toHaveLength(1);
await vi.advanceTimersByTimeAsync(9);
expect(settled).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1);
expect(settled).toHaveBeenCalledExactlyOnceWith(expect.any(SnowLumaTimeoutError));
expect(settled.mock.calls[0]![0]).toHaveProperty('timeoutMs', 30);
expect(vi.getTimerCount()).toBe(0);
} finally {
client.close();
await request;
}
});
it.each(['timeout', 'abort'] as const)(
'keeps a shared opening connection usable after one request ends by %s',
async (outcome) => {
vi.useFakeTimers();
const client = new SnowLumaWebSocketClient({ webSocket: MockWebSocket });
const controller = new AbortController();
const remove = vi.spyOn(controller.signal, 'removeEventListener');
const firstSettled = vi.fn();
const first = client
.getStatus({ echo: 'first', timeoutMs: 20, signal: controller.signal })
.then(firstSettled, firstSettled);
const socket = MockWebSocket.instances.at(-1)!;
const second = client.getStatus({ echo: 'second', timeoutMs: 100 });
const secondResult = second.catch((error: unknown) => error);
try {
if (outcome === 'abort') {
controller.abort('caller cancelled');
}
await vi.advanceTimersByTimeAsync(20);
expect(firstSettled).toHaveBeenCalledExactlyOnceWith(
expect.any(outcome === 'timeout' ? SnowLumaTimeoutError : SnowLumaAbortError),
);
if (outcome === 'abort') {
expect(firstSettled.mock.calls[0]![0]).toHaveProperty(
'cause',
'caller cancelled',
);
}
expect(remove).toHaveBeenCalledWith('abort', expect.any(Function));
expect(socket.readyState).toBe(0);
socket.open();
await vi.advanceTimersByTimeAsync(0);
expect(socket.sent.map((value) => JSON.parse(value).echo)).toEqual(['second']);
socket.message(
JSON.stringify({
status: 'ok',
retcode: 0,
data: { online: true },
echo: 'second',
}),
);
await expect(second).resolves.toEqual({ online: true });
expect(client.isConnected).toBe(true);
expect(vi.getTimerCount()).toBe(0);
} finally {
client.close();
await Promise.all([first, secondResult]);
}
},
);
it('allows zero timeout through connection and response waits', async () => {
vi.useFakeTimers();
const client = new SnowLumaWebSocketClient({
webSocket: MockWebSocket,
requestTimeoutMs: 10,
});
const settled = vi.fn();
const request = client
.getStatus({ timeoutMs: 0, echo: 'unlimited' })
.then(settled, settled);
const socket = MockWebSocket.instances.at(-1)!;
try {
await vi.advanceTimersByTimeAsync(100);
expect(settled).not.toHaveBeenCalled();
socket.open();
await vi.advanceTimersByTimeAsync(100);
expect(settled).not.toHaveBeenCalled();
expect(socket.sent).toHaveLength(1);
socket.message(
JSON.stringify({
status: 'ok',
retcode: 0,
data: { online: true },
echo: 'unlimited',
}),
);
await request;
expect(settled).toHaveBeenCalledExactlyOnceWith({ online: true });
expect(vi.getTimerCount()).toBe(0);
} finally {
client.close();
await request;
}
});
it.each(['close', 'constructor failure', 'pre-aborted'] as const)(
'cleans request controls when opening ends with %s',
async (outcome) => {
vi.useFakeTimers();
const failure = new Error('opening failed');
class OpeningSocket extends MockWebSocket {
constructor(url: string) {
if (outcome === 'constructor failure') {
throw failure;
}
super(url);
}
}
const client = new SnowLumaWebSocketClient({ webSocket: OpeningSocket });
const controller = new AbortController();
const remove = vi.spyOn(controller.signal, 'removeEventListener');
if (outcome === 'pre-aborted') {
controller.abort(failure);
}
const before = MockWebSocket.instances.length;
const result = client
.getStatus({ timeoutMs: 20, signal: controller.signal })
.catch((error: unknown) => error);
if (outcome === 'close') {
MockWebSocket.instances.at(-1)!.close();
expect(await result).toBeInstanceOf(SnowLumaConnectionError);
} else if (outcome === 'constructor failure') {
expect(await result).toBe(failure);
} else {
expect(await result).toBeInstanceOf(SnowLumaAbortError);
expect(MockWebSocket.instances).toHaveLength(before);
}
expect(vi.getTimerCount()).toBe(0);
expect(remove).toHaveBeenCalledWith('abort', expect.any(Function));
client.close();
},
);
it('rejects duplicate in-flight echoes without replacing the original request', async () => {
const client = new SnowLumaWebSocketClient({ webSocket: MockWebSocket });
const opening = client.connect();
const socket = MockWebSocket.instances.at(-1)!;
socket.open();
await opening;
const first = client.getStatus({ echo: 'same' });
await vi.waitFor(() => expect(socket.sent).toHaveLength(1));
await expect(client.getStatus({ echo: 'same' })).rejects.toThrow('already pending');
expect(socket.sent).toHaveLength(1);
socket.message(
JSON.stringify({ status: 'ok', retcode: 0, data: { online: true }, echo: 'same' }),
);
await expect(first).resolves.toEqual({ online: true });
client.close();
});
it.each(['response', 'abort', 'timeout', 'send failure', 'invalid response', 'close'] as const)(
'releases the timer and abort listener after %s',
async (outcome) => {
vi.useFakeTimers();
const client = new SnowLumaWebSocketClient({
webSocket: MockWebSocket,
requestTimeoutMs: 1000,
});
const opening = client.connect();
const socket = MockWebSocket.instances.at(-1)!;
socket.open();
await opening;
const controller = new AbortController();
const remove = vi.spyOn(controller.signal, 'removeEventListener');
if (outcome === 'send failure') {
vi.spyOn(socket, 'send').mockImplementation(() => {
throw new Error('send failed');
});
}
const request = client.getStatus({ echo: 'request', signal: controller.signal });
const settled = request.then(
() => 'resolved',
() => 'rejected',
);
await vi.advanceTimersByTimeAsync(0);
switch (outcome) {
case 'response':
socket.message(
JSON.stringify({ status: 'ok', retcode: 0, data: {}, echo: 'request' }),
);
break;
case 'invalid response':
socket.message(JSON.stringify({ echo: 'request' }));
break;
case 'abort':
controller.abort();
break;
case 'timeout':
await vi.advanceTimersByTimeAsync(1000);
break;
case 'close':
client.close();
break;
}
expect(await settled).toBe(outcome === 'response' ? 'resolved' : 'rejected');
expect(vi.getTimerCount()).toBe(0);
expect(remove).toHaveBeenCalledWith('abort', expect.any(Function));
client.close();
},
);
it('reconnects after a remote close and cancels scheduled retries when closed by the caller', async () => {
vi.useFakeTimers();
const client = new SnowLumaWebSocketClient({
webSocket: MockWebSocket,
reconnect: { minDelayMs: 10 },
});
const opening = client.connect();
const first = MockWebSocket.instances.at(-1)!;
first.open();
await opening;
first.close();
await vi.advanceTimersByTimeAsync(10);
const second = MockWebSocket.instances.at(-1)!;
expect(second).not.toBe(first);
second.open();
await vi.advanceTimersByTimeAsync(0);
expect(client.isConnected).toBe(true);
second.close();
client.close();
expect(vi.getTimerCount()).toBe(0);
});
it('retries after the WebSocket constructor throws', async () => {
let attempts = 0;
class RetrySocket extends MockWebSocket {
constructor(url: string) {
if (attempts++ === 0) {
throw new Error('constructor failed');
}
super(url);
}
}
const client = new SnowLumaWebSocketClient({ webSocket: RetrySocket });
await expect(client.connect()).rejects.toThrow('constructor failed');
const connecting = client.connect();
expect(attempts).toBe(2);
MockWebSocket.instances.at(-1)!.open();
await connecting;
client.close();
});
it('ignores events from a closed socket after a new connection opens', async () => {
class DelayedCloseSocket extends MockWebSocket {
override close(): void {
this.readyState = 2;
}
}
const client = new SnowLumaWebSocketClient({ webSocket: DelayedCloseSocket });
const first = client.connect();
const oldSocket = MockWebSocket.instances.at(-1)!;
oldSocket.open();
await first;
client.close();
const second = client.connect();
const currentSocket = MockWebSocket.instances.at(-1)!;
currentSocket.open();
await second;
const raw = vi.fn();
client.on('raw', raw);
oldSocket.message(JSON.stringify(PRIVATE_EVENT));
oldSocket.emit('close', { code: 1000 });
expect(client.isConnected).toBe(true);
expect(raw).not.toHaveBeenCalled();
client.close();
});
it('settles an opening connection when the caller closes it before the close event', async () => {
class DelayedCloseSocket extends MockWebSocket {
override close(): void {
this.readyState = 2;
}
}
const client = new SnowLumaWebSocketClient({ webSocket: DelayedCloseSocket });
const opening = client.connect();
const oldSocket = MockWebSocket.instances.at(-1)!;
const result = expect(opening).rejects.toThrow('closed');
client.close();
await result;
oldSocket.open();
expect(client.isConnected).toBe(false);
});
it('matches API responses by echo', async () => {
MockWebSocket.instances = [];
const client = new SnowLumaWebSocketClient({
url: 'ws://127.0.0.1:3001/',
accessToken: 'token',
webSocket: MockWebSocket,
});
const connecting = client.connect();
const socket = MockWebSocket.instances[0]!;
expect(socket.url).toContain('access_token=token');
socket.open();
await connecting;
const status = client.getStatus();
await vi.waitFor(() => expect(socket.sent).toHaveLength(1));
const request = JSON.parse(socket.sent[0]!) as { echo: string };
socket.message(
JSON.stringify({
status: 'ok',
retcode: 0,
data: { online: true, good: true },
echo: request.echo,
}),
);
await expect(status).resolves.toEqual({ online: true, good: true });
});
it('runs command handlers for matching message events', async () => {
MockWebSocket.instances = [];
const client = new SnowLumaWebSocketClient({
url: 'ws://127.0.0.1:3001/',
webSocket: MockWebSocket,
});
const handler = vi.fn();
client.command('ping', handler);
const connecting = client.connect();
const socket = MockWebSocket.instances[0]!;
socket.open();
await connecting;
socket.message(JSON.stringify(PRIVATE_EVENT));
await vi.waitFor(() => {
expect(handler).toHaveBeenCalledTimes(1);
expect(handler.mock.calls[0]![2]).toMatchObject({ args: ['a', 'b'] });
});
});
it('supports caller AbortSignal cancellation for pending requests', async () => {
MockWebSocket.instances = [];
const client = new SnowLumaWebSocketClient({
url: 'ws://127.0.0.1:3001/',
webSocket: MockWebSocket,
});
const controller = new AbortController();
const connecting = client.connect();
const socket = MockWebSocket.instances[0]!;
socket.open();
await connecting;
const request = client.getStatus({ signal: controller.signal });
await vi.waitFor(() => expect(socket.sent).toHaveLength(1));
controller.abort();
await expect(request).rejects.toBeInstanceOf(SnowLumaAbortError);
});
it('dispatches bot_status notices to onBotStatus', async () => {
MockWebSocket.instances = [];
const client = new SnowLumaWebSocketClient({
url: 'ws://127.0.0.1:3001/',
webSocket: MockWebSocket,
});
const handler = vi.fn();
client.onBotStatus(handler);
const connecting = client.connect();
const socket = MockWebSocket.instances[0]!;
socket.open();
await connecting;
socket.message(
JSON.stringify({
time: 1_710_000_000,
self_id: 10000,
post_type: 'notice',
notice_type: 'bot_status',
sub_type: 'offline',
user_id: 10000,
}),
);
await vi.waitFor(() => {
expect(handler).toHaveBeenCalledTimes(1);
expect(handler.mock.calls[0]![0]).toMatchObject({
notice_type: 'bot_status',
sub_type: 'offline',
user_id: 10000,
});
});
});
});
+225
View File
@@ -0,0 +1,225 @@
import { describe, expect, it, vi } from 'vitest';
import {
addSocketListener,
closeInfo,
normalizeReconnect,
rawToString,
unwrapEventPayload,
unwrapMessagePayload,
WEBSOCKET_CLOSED,
WEBSOCKET_OPEN,
} from '../src/client/websocket-utils';
import type { WebSocketLike } from '../src/client/websocket-types';
function stubSocket(extra: Partial<WebSocketLike> = {}): WebSocketLike {
return {
readyState: 0,
send() {},
close() {},
...extra,
};
}
describe('websocket readyState constants', () => {
it('matches the standard WebSocket readyState values', () => {
expect(WEBSOCKET_OPEN).toBe(1);
expect(WEBSOCKET_CLOSED).toBe(3);
});
});
describe('normalizeReconnect', () => {
it('treats a disabled or omitted value as no reconnect policy', () => {
expect(normalizeReconnect(false)).toBeNull();
expect(normalizeReconnect(undefined)).toBeNull();
});
it('turns a boolean enable flag into an empty options object', () => {
expect(normalizeReconnect(true)).toEqual({});
});
it('returns the given reconnect options object unchanged', () => {
const options = { retries: 4, minDelayMs: 250, maxDelayMs: 8_000 };
expect(normalizeReconnect(options)).toBe(options);
expect(normalizeReconnect({ retries: 0 })).toEqual({ retries: 0 });
expect(normalizeReconnect({})).toEqual({});
});
});
describe('addSocketListener', () => {
it('binds and unbinds through addEventListener when present', () => {
const listener = vi.fn();
const socket = stubSocket({
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
on: vi.fn(),
});
const off = addSocketListener(socket, 'message', listener);
expect(socket.addEventListener).toHaveBeenCalledTimes(1);
expect(socket.addEventListener).toHaveBeenCalledWith('message', listener);
expect(socket.on).not.toHaveBeenCalled();
off();
expect(socket.removeEventListener).toHaveBeenCalledTimes(1);
expect(socket.removeEventListener).toHaveBeenCalledWith('message', listener);
});
it('does not throw on cleanup when removeEventListener is absent', () => {
const socket = stubSocket({ addEventListener: vi.fn() });
const off = addSocketListener(socket, 'open', vi.fn());
expect(off).not.toThrow();
});
it('binds through on() and unbinds through off() when addEventListener is missing', () => {
const listener = vi.fn();
const socket = stubSocket({
on: vi.fn(),
off: vi.fn(),
removeListener: vi.fn(),
});
const off = addSocketListener(socket, 'close', listener);
expect(socket.on).toHaveBeenCalledTimes(1);
expect(socket.on).toHaveBeenCalledWith('close', listener);
off();
expect(socket.off).toHaveBeenCalledTimes(1);
expect(socket.off).toHaveBeenCalledWith('close', listener);
expect(socket.removeListener).not.toHaveBeenCalled();
});
it('falls back to removeListener when off() is absent', () => {
const listener = vi.fn();
const socket = stubSocket({
on: vi.fn(),
removeListener: vi.fn(),
});
const off = addSocketListener(socket, 'error', listener);
off();
expect(socket.removeListener).toHaveBeenCalledTimes(1);
expect(socket.removeListener).toHaveBeenCalledWith('error', listener);
});
it('cleanup is a no-op when on() has neither off nor removeListener', () => {
const socket = stubSocket({ on: vi.fn() });
const off = addSocketListener(socket, 'message', vi.fn());
expect(off).not.toThrow();
});
it('returns a no-op cleanup when the socket has no listener APIs', () => {
const off = addSocketListener(stubSocket(), 'open', vi.fn());
expect(off).not.toThrow();
});
});
describe('unwrapMessagePayload', () => {
it('returns the data field from a JSON object envelope', () => {
expect(unwrapMessagePayload({ data: '{"ok":true}' })).toBe('{"ok":true}');
expect(unwrapMessagePayload({ data: { text: 'hi' } })).toEqual({ text: 'hi' });
expect(unwrapMessagePayload({ data: 0 })).toBe(0);
expect(unwrapMessagePayload({ data: undefined })).toBeUndefined();
});
it('returns the original value when it is not a data envelope', () => {
expect(unwrapMessagePayload('raw-text')).toBe('raw-text');
expect(unwrapMessagePayload(null)).toBeNull();
expect(unwrapMessagePayload([{ data: 1 }])).toEqual([{ data: 1 }]);
expect(unwrapMessagePayload({ type: 'message' })).toEqual({ type: 'message' });
});
});
describe('unwrapEventPayload', () => {
it('returns the error field from a JSON object envelope', () => {
expect(unwrapEventPayload({ error: 'socket failed' })).toBe('socket failed');
expect(unwrapEventPayload({ error: { message: 'boom' } })).toEqual({ message: 'boom' });
expect(unwrapEventPayload({ error: undefined })).toBeUndefined();
});
it('returns the original value when it is not an error envelope', () => {
expect(unwrapEventPayload('plain-error')).toBe('plain-error');
expect(unwrapEventPayload(null)).toBeNull();
expect(unwrapEventPayload([{ error: 1 }])).toEqual([{ error: 1 }]);
expect(unwrapEventPayload({ type: 'error' })).toEqual({ type: 'error' });
});
});
describe('rawToString', () => {
it('returns strings unchanged', () => {
expect(rawToString('{"echo":1}')).toBe('{"echo":1}');
expect(rawToString('')).toBe('');
});
it('decodes ArrayBuffer and ArrayBufferView payloads as UTF-8', () => {
const bytes = Uint8Array.from([0x68, 0x69]);
expect(rawToString(bytes.buffer)).toBe('hi');
expect(rawToString(bytes)).toBe('hi');
expect(rawToString(Buffer.from([0x68, 0x69]))).toBe('hi');
expect(rawToString(new DataView(bytes.buffer))).toBe('hi');
expect(rawToString(Uint8Array.from([0x21, 0x68, 0x69, 0x21]).subarray(1, 3))).toBe('hi');
expect(rawToString(new ArrayBuffer(0))).toBe('');
});
it('rejects Blob payloads that cannot be read synchronously', () => {
expect(() => rawToString(new Blob(['hi']))).toThrow(
'Blob WebSocket messages are not supported synchronously',
);
});
it('uses toString() on objects and String() on primitives', () => {
expect(rawToString({ toString: () => 'from-toString' })).toBe('from-toString');
expect(rawToString([1, 2])).toBe('1,2');
expect(rawToString({ a: 1 })).toBe('[object Object]');
expect(rawToString(42)).toBe('42');
expect(rawToString(false)).toBe('false');
expect(rawToString(null)).toBe('null');
expect(rawToString(undefined)).toBe('undefined');
});
});
describe('closeInfo', () => {
it('reads code and reason from a CloseEvent-like object', () => {
expect(closeInfo([{ code: 1000, reason: 'normal', wasClean: true }])).toEqual({
code: 1000,
reason: 'normal',
});
expect(closeInfo([{ code: 1001 }])).toEqual({
code: 1001,
reason: undefined,
});
expect(closeInfo([{ reason: 'going away' }])).toEqual({
code: undefined,
reason: 'going away',
});
expect(closeInfo([{ code: '1000', reason: 1 }])).toEqual({
code: undefined,
reason: undefined,
});
});
it('reads positional Node-style close arguments', () => {
expect(closeInfo([1006, 'abnormal'])).toEqual({
code: 1006,
reason: 'abnormal',
});
expect(closeInfo([1000])).toEqual({
code: 1000,
reason: undefined,
});
expect(closeInfo(['1000', 1])).toEqual({
code: undefined,
reason: undefined,
});
expect(closeInfo([])).toEqual({
code: undefined,
reason: undefined,
});
expect(closeInfo([[1000, 'nested']])).toEqual({
code: undefined,
reason: undefined,
});
});
});