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
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:
commit
fa9c73363d
1367 files changed
+355735
No files matched your search
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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&B[C]');
|
||||
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,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]);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 };
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,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;.png'),
|
||||
message.customReply('literal , [CQ:at,qq=all] &[],'),
|
||||
message.raw('extension', { value: '[],& &[],' }),
|
||||
])('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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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&[CQ:image,file=a,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] [ &';
|
||||
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 = '[CQ:reply,id=7]';
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user