SnowLuma v2.4.2 source snapshot
Dev Build / cleanup (push) Waiting to run
Dev Build / typecheck (push) Waiting to run
Publish MCP to npm / Build, test, and publish MCP (push) Waiting to run
Publish SDK to npm / Build, test, and publish SDK (push) Waiting to run
Sync action catalog to docs / sync (push) Waiting to run
Publish UI to npm / Build and publish UI (push) Waiting to run
Build and Release / preflight (push) Waiting to run

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

No files matched your search

+749
View File
@@ -0,0 +1,749 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
SnowLumaAbortError,
SnowLumaApiError,
SnowLumaConnectionError,
SnowLumaParseError,
SnowLumaTimeoutError,
SnowLumaWebSocketClient,
type OneBotPrivateMessageEvent,
} from '../src';
class MockWebSocket {
static instances: MockWebSocket[] = [];
readyState = 0;
sent: string[] = [];
readonly listeners = new Map<string, Set<(...args: unknown[]) => void>>();
constructor(
readonly url: string,
readonly protocols?: string | string[],
) {
MockWebSocket.instances.push(this);
}
addEventListener(event: string, listener: (...args: unknown[]) => void): void {
let listeners = this.listeners.get(event);
if (!listeners) {
listeners = new Set();
this.listeners.set(event, listeners);
}
listeners.add(listener);
}
removeEventListener(event: string, listener: (...args: unknown[]) => void): void {
this.listeners.get(event)?.delete(listener);
}
send(data: string): void {
this.sent.push(data);
}
close(code?: number, reason?: string): void {
this.readyState = 3;
this.emit('close', { code, reason });
}
open(): void {
this.readyState = 1;
this.emit('open');
}
message(data: unknown): void {
this.emit('message', { data });
}
emit(event: string, ...args: unknown[]): void {
for (const listener of this.listeners.get(event) ?? []) {
listener(...args);
}
}
}
const PRIVATE_EVENT: OneBotPrivateMessageEvent = {
time: 1,
self_id: 10000,
post_type: 'message',
message_type: 'private',
sub_type: 'friend',
message_id: 7,
message_seq: 8,
user_id: 10001,
message: [{ type: 'text', data: { text: '/ping a b' } }],
raw_message: '/ping a b',
font: 0,
sender: { user_id: 10001, nickname: 'tester' },
};
describe('WebSocket streaming', () => {
afterEach(() => vi.useRealTimers());
async function connected() {
const client = new SnowLumaWebSocketClient({
webSocket: MockWebSocket,
requestTimeoutMs: 1000,
});
const opening = client.connect();
const socket = MockWebSocket.instances.at(-1)!;
socket.open();
await opening;
return { client, socket };
}
const data = { type: 'stream', data_type: 'data_chunk', data: 'part' };
const complete = { type: 'response', data_type: 'data_complete', data: 'done' };
const frame = (echo: string, payload: unknown) =>
JSON.stringify({
status: 'ok',
retcode: 0,
stream: 'stream-action',
echo,
data: payload,
});
it('routes interleaved streams, normal responses and events independently', async () => {
const { client, socket } = await connected();
const event = vi.fn();
client.onEvent(event);
const first = client.stream('test_download_stream', {}, { echo: 'a' });
const second = client.stream('test_download_stream', {}, { echo: 'b' });
const a = first.next();
const b = second.next();
const status = client.getStatus({ echo: 'ordinary' });
await vi.waitFor(() => expect(socket.sent).toHaveLength(3));
socket.message(frame('b', data));
socket.message(frame('a', data));
socket.message(JSON.stringify(PRIVATE_EVENT));
socket.message(
JSON.stringify({ status: 'ok', retcode: 0, echo: 'ordinary', data: { online: true } }),
);
await expect(status).resolves.toEqual({ online: true });
expect((await a).value).toEqual(data);
expect((await b).value).toEqual(data);
socket.message(frame('a', complete));
socket.message(frame('b', complete));
expect((await first.next()).value).toEqual(complete);
expect((await second.next()).value).toEqual(complete);
expect((await first.next()).done).toBe(true);
expect((await second.next()).done).toBe(true);
await vi.waitFor(() => expect(event).toHaveBeenCalledOnce());
client.close();
});
it('shares echo collision checks with single-response calls and preserves later requests', async () => {
const { client, socket } = await connected();
const stream = client.stream('test_download_stream', {}, { echo: 'same' });
const first = stream.next();
await vi.waitFor(() => expect(socket.sent).toHaveLength(1));
await expect(client.getStatus({ echo: 'same' })).rejects.toThrow('already pending');
await expect(
client.stream('test_download_stream', {}, { echo: 'same' }).next(),
).rejects.toThrow('already pending');
socket.message(frame('same', data));
await first;
socket.message(frame('same', complete));
const ordinary = client.getStatus({ echo: 'same' });
await vi.waitFor(() => expect(socket.sent).toHaveLength(2));
await stream.return();
socket.message(
JSON.stringify({ status: 'ok', retcode: 0, data: { online: true }, echo: 'same' }),
);
await expect(ordinary).resolves.toEqual({ online: true });
client.close();
});
it.each([
'abort',
'timeout',
'close',
'invalid frame',
'invalid JSON',
'send failure',
'return',
'complete',
] as const)('releases stream timers and listeners after %s', async (outcome) => {
vi.useFakeTimers();
const { client, socket } = await connected();
const controller = new AbortController();
const remove = vi.spyOn(controller.signal, 'removeEventListener');
if (outcome === 'send failure') {
vi.spyOn(socket, 'send').mockImplementationOnce(() => {
throw new Error('send');
});
}
const stream = client.stream(
'test_download_stream',
{},
{ echo: 's', signal: controller.signal },
);
const next = stream.next();
const settled = next.then(
() => undefined,
(error: unknown) => error,
);
await vi.advanceTimersByTimeAsync(0);
switch (outcome) {
case 'abort':
controller.abort();
break;
case 'timeout':
await vi.advanceTimersByTimeAsync(1000);
break;
case 'close':
socket.close();
break;
case 'invalid frame':
socket.message(JSON.stringify({ echo: 's' }));
break;
case 'invalid JSON':
socket.message('bad json');
break;
case 'return':
socket.message(frame('s', data));
await next;
await stream.return();
break;
case 'complete':
socket.message(frame('s', complete));
await next;
await stream.next();
break;
}
const error = await settled;
const expected = {
abort: SnowLumaAbortError,
timeout: SnowLumaTimeoutError,
close: SnowLumaConnectionError,
'send failure': SnowLumaConnectionError,
'invalid frame': SnowLumaParseError,
'invalid JSON': SnowLumaParseError,
};
if (outcome in expected) {
expect(error).toBeInstanceOf(expected[outcome as keyof typeof expected]);
} else {
expect(error).toBeUndefined();
}
expect(vi.getTimerCount()).toBe(0);
expect(remove).toHaveBeenCalled();
client.close();
});
it.each(['frames', 'bytes'] as const)(
'fails a slow consumer when queued %s exceed its limit',
async (limit) => {
const { client, socket } = await connected();
const stream = client.stream(
'test_download_stream',
{},
{
echo: 's',
maxBufferedFrames: limit === 'frames' ? 1 : 256,
maxBufferBytes: limit === 'bytes' ? 180 : 10000,
},
);
const next = stream.next();
await vi.waitFor(() => expect(socket.sent).toHaveLength(1));
socket.message(frame('s', data));
await next;
socket.message(frame('s', data));
socket.message(frame('s', data));
await expect(stream.next()).rejects.toThrow('buffer limit');
client.close();
},
);
it('throws a terminal API error after previously delivered frames', async () => {
const { client, socket } = await connected();
const stream = client.stream('test_download_stream', {}, { echo: 's' });
const next = stream.next();
await vi.waitFor(() => expect(socket.sent).toHaveLength(1));
socket.message(frame('s', data));
await next;
socket.message(
JSON.stringify({
status: 'failed',
retcode: 1500,
echo: 's',
data: { type: 'error' },
wording: 'failed',
}),
);
await expect(stream.next()).rejects.toBeInstanceOf(SnowLumaApiError);
client.close();
});
it('times out while opening without sending a request later', async () => {
vi.useFakeTimers();
const client = new SnowLumaWebSocketClient({ webSocket: MockWebSocket });
const result = expect(
client.stream('test_download_stream', {}, { timeoutMs: 20 }).next(),
).rejects.toBeInstanceOf(SnowLumaTimeoutError);
const socket = MockWebSocket.instances.at(-1)!;
await vi.advanceTimersByTimeAsync(20);
await result;
socket.open();
expect(socket.sent).toHaveLength(0);
expect(vi.getTimerCount()).toBe(0);
client.close();
});
});
describe('SnowLumaWebSocketClient', () => {
afterEach(() => {
vi.useRealTimers();
});
it('times out while opening and never sends the expired action after a late open', async () => {
vi.useFakeTimers();
const client = new SnowLumaWebSocketClient({ webSocket: MockWebSocket });
const settled = vi.fn();
const request = client.getStatus({ timeoutMs: 20 }).then(settled, settled);
const socket = MockWebSocket.instances.at(-1)!;
try {
await vi.advanceTimersByTimeAsync(20);
expect(settled).toHaveBeenCalledExactlyOnceWith(expect.any(SnowLumaTimeoutError));
expect(settled.mock.calls[0]![0]).toHaveProperty('timeoutMs', 20);
socket.open();
await vi.advanceTimersByTimeAsync(0);
expect(socket.sent).toHaveLength(0);
expect(client.isConnected).toBe(true);
expect(vi.getTimerCount()).toBe(0);
} finally {
client.close();
await request;
}
});
it('uses the client default as one deadline across connection and response waits', async () => {
vi.useFakeTimers();
const client = new SnowLumaWebSocketClient({
webSocket: MockWebSocket,
requestTimeoutMs: 30,
});
const settled = vi.fn();
const request = client.getStatus({ echo: 'deadline' }).then(settled, settled);
const socket = MockWebSocket.instances.at(-1)!;
try {
await vi.advanceTimersByTimeAsync(20);
socket.open();
await vi.advanceTimersByTimeAsync(0);
expect(socket.sent).toHaveLength(1);
await vi.advanceTimersByTimeAsync(9);
expect(settled).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1);
expect(settled).toHaveBeenCalledExactlyOnceWith(expect.any(SnowLumaTimeoutError));
expect(settled.mock.calls[0]![0]).toHaveProperty('timeoutMs', 30);
expect(vi.getTimerCount()).toBe(0);
} finally {
client.close();
await request;
}
});
it.each(['timeout', 'abort'] as const)(
'keeps a shared opening connection usable after one request ends by %s',
async (outcome) => {
vi.useFakeTimers();
const client = new SnowLumaWebSocketClient({ webSocket: MockWebSocket });
const controller = new AbortController();
const remove = vi.spyOn(controller.signal, 'removeEventListener');
const firstSettled = vi.fn();
const first = client
.getStatus({ echo: 'first', timeoutMs: 20, signal: controller.signal })
.then(firstSettled, firstSettled);
const socket = MockWebSocket.instances.at(-1)!;
const second = client.getStatus({ echo: 'second', timeoutMs: 100 });
const secondResult = second.catch((error: unknown) => error);
try {
if (outcome === 'abort') {
controller.abort('caller cancelled');
}
await vi.advanceTimersByTimeAsync(20);
expect(firstSettled).toHaveBeenCalledExactlyOnceWith(
expect.any(outcome === 'timeout' ? SnowLumaTimeoutError : SnowLumaAbortError),
);
if (outcome === 'abort') {
expect(firstSettled.mock.calls[0]![0]).toHaveProperty(
'cause',
'caller cancelled',
);
}
expect(remove).toHaveBeenCalledWith('abort', expect.any(Function));
expect(socket.readyState).toBe(0);
socket.open();
await vi.advanceTimersByTimeAsync(0);
expect(socket.sent.map((value) => JSON.parse(value).echo)).toEqual(['second']);
socket.message(
JSON.stringify({
status: 'ok',
retcode: 0,
data: { online: true },
echo: 'second',
}),
);
await expect(second).resolves.toEqual({ online: true });
expect(client.isConnected).toBe(true);
expect(vi.getTimerCount()).toBe(0);
} finally {
client.close();
await Promise.all([first, secondResult]);
}
},
);
it('allows zero timeout through connection and response waits', async () => {
vi.useFakeTimers();
const client = new SnowLumaWebSocketClient({
webSocket: MockWebSocket,
requestTimeoutMs: 10,
});
const settled = vi.fn();
const request = client
.getStatus({ timeoutMs: 0, echo: 'unlimited' })
.then(settled, settled);
const socket = MockWebSocket.instances.at(-1)!;
try {
await vi.advanceTimersByTimeAsync(100);
expect(settled).not.toHaveBeenCalled();
socket.open();
await vi.advanceTimersByTimeAsync(100);
expect(settled).not.toHaveBeenCalled();
expect(socket.sent).toHaveLength(1);
socket.message(
JSON.stringify({
status: 'ok',
retcode: 0,
data: { online: true },
echo: 'unlimited',
}),
);
await request;
expect(settled).toHaveBeenCalledExactlyOnceWith({ online: true });
expect(vi.getTimerCount()).toBe(0);
} finally {
client.close();
await request;
}
});
it.each(['close', 'constructor failure', 'pre-aborted'] as const)(
'cleans request controls when opening ends with %s',
async (outcome) => {
vi.useFakeTimers();
const failure = new Error('opening failed');
class OpeningSocket extends MockWebSocket {
constructor(url: string) {
if (outcome === 'constructor failure') {
throw failure;
}
super(url);
}
}
const client = new SnowLumaWebSocketClient({ webSocket: OpeningSocket });
const controller = new AbortController();
const remove = vi.spyOn(controller.signal, 'removeEventListener');
if (outcome === 'pre-aborted') {
controller.abort(failure);
}
const before = MockWebSocket.instances.length;
const result = client
.getStatus({ timeoutMs: 20, signal: controller.signal })
.catch((error: unknown) => error);
if (outcome === 'close') {
MockWebSocket.instances.at(-1)!.close();
expect(await result).toBeInstanceOf(SnowLumaConnectionError);
} else if (outcome === 'constructor failure') {
expect(await result).toBe(failure);
} else {
expect(await result).toBeInstanceOf(SnowLumaAbortError);
expect(MockWebSocket.instances).toHaveLength(before);
}
expect(vi.getTimerCount()).toBe(0);
expect(remove).toHaveBeenCalledWith('abort', expect.any(Function));
client.close();
},
);
it('rejects duplicate in-flight echoes without replacing the original request', async () => {
const client = new SnowLumaWebSocketClient({ webSocket: MockWebSocket });
const opening = client.connect();
const socket = MockWebSocket.instances.at(-1)!;
socket.open();
await opening;
const first = client.getStatus({ echo: 'same' });
await vi.waitFor(() => expect(socket.sent).toHaveLength(1));
await expect(client.getStatus({ echo: 'same' })).rejects.toThrow('already pending');
expect(socket.sent).toHaveLength(1);
socket.message(
JSON.stringify({ status: 'ok', retcode: 0, data: { online: true }, echo: 'same' }),
);
await expect(first).resolves.toEqual({ online: true });
client.close();
});
it.each(['response', 'abort', 'timeout', 'send failure', 'invalid response', 'close'] as const)(
'releases the timer and abort listener after %s',
async (outcome) => {
vi.useFakeTimers();
const client = new SnowLumaWebSocketClient({
webSocket: MockWebSocket,
requestTimeoutMs: 1000,
});
const opening = client.connect();
const socket = MockWebSocket.instances.at(-1)!;
socket.open();
await opening;
const controller = new AbortController();
const remove = vi.spyOn(controller.signal, 'removeEventListener');
if (outcome === 'send failure') {
vi.spyOn(socket, 'send').mockImplementation(() => {
throw new Error('send failed');
});
}
const request = client.getStatus({ echo: 'request', signal: controller.signal });
const settled = request.then(
() => 'resolved',
() => 'rejected',
);
await vi.advanceTimersByTimeAsync(0);
switch (outcome) {
case 'response':
socket.message(
JSON.stringify({ status: 'ok', retcode: 0, data: {}, echo: 'request' }),
);
break;
case 'invalid response':
socket.message(JSON.stringify({ echo: 'request' }));
break;
case 'abort':
controller.abort();
break;
case 'timeout':
await vi.advanceTimersByTimeAsync(1000);
break;
case 'close':
client.close();
break;
}
expect(await settled).toBe(outcome === 'response' ? 'resolved' : 'rejected');
expect(vi.getTimerCount()).toBe(0);
expect(remove).toHaveBeenCalledWith('abort', expect.any(Function));
client.close();
},
);
it('reconnects after a remote close and cancels scheduled retries when closed by the caller', async () => {
vi.useFakeTimers();
const client = new SnowLumaWebSocketClient({
webSocket: MockWebSocket,
reconnect: { minDelayMs: 10 },
});
const opening = client.connect();
const first = MockWebSocket.instances.at(-1)!;
first.open();
await opening;
first.close();
await vi.advanceTimersByTimeAsync(10);
const second = MockWebSocket.instances.at(-1)!;
expect(second).not.toBe(first);
second.open();
await vi.advanceTimersByTimeAsync(0);
expect(client.isConnected).toBe(true);
second.close();
client.close();
expect(vi.getTimerCount()).toBe(0);
});
it('retries after the WebSocket constructor throws', async () => {
let attempts = 0;
class RetrySocket extends MockWebSocket {
constructor(url: string) {
if (attempts++ === 0) {
throw new Error('constructor failed');
}
super(url);
}
}
const client = new SnowLumaWebSocketClient({ webSocket: RetrySocket });
await expect(client.connect()).rejects.toThrow('constructor failed');
const connecting = client.connect();
expect(attempts).toBe(2);
MockWebSocket.instances.at(-1)!.open();
await connecting;
client.close();
});
it('ignores events from a closed socket after a new connection opens', async () => {
class DelayedCloseSocket extends MockWebSocket {
override close(): void {
this.readyState = 2;
}
}
const client = new SnowLumaWebSocketClient({ webSocket: DelayedCloseSocket });
const first = client.connect();
const oldSocket = MockWebSocket.instances.at(-1)!;
oldSocket.open();
await first;
client.close();
const second = client.connect();
const currentSocket = MockWebSocket.instances.at(-1)!;
currentSocket.open();
await second;
const raw = vi.fn();
client.on('raw', raw);
oldSocket.message(JSON.stringify(PRIVATE_EVENT));
oldSocket.emit('close', { code: 1000 });
expect(client.isConnected).toBe(true);
expect(raw).not.toHaveBeenCalled();
client.close();
});
it('settles an opening connection when the caller closes it before the close event', async () => {
class DelayedCloseSocket extends MockWebSocket {
override close(): void {
this.readyState = 2;
}
}
const client = new SnowLumaWebSocketClient({ webSocket: DelayedCloseSocket });
const opening = client.connect();
const oldSocket = MockWebSocket.instances.at(-1)!;
const result = expect(opening).rejects.toThrow('closed');
client.close();
await result;
oldSocket.open();
expect(client.isConnected).toBe(false);
});
it('matches API responses by echo', async () => {
MockWebSocket.instances = [];
const client = new SnowLumaWebSocketClient({
url: 'ws://127.0.0.1:3001/',
accessToken: 'token',
webSocket: MockWebSocket,
});
const connecting = client.connect();
const socket = MockWebSocket.instances[0]!;
expect(socket.url).toContain('access_token=token');
socket.open();
await connecting;
const status = client.getStatus();
await vi.waitFor(() => expect(socket.sent).toHaveLength(1));
const request = JSON.parse(socket.sent[0]!) as { echo: string };
socket.message(
JSON.stringify({
status: 'ok',
retcode: 0,
data: { online: true, good: true },
echo: request.echo,
}),
);
await expect(status).resolves.toEqual({ online: true, good: true });
});
it('runs command handlers for matching message events', async () => {
MockWebSocket.instances = [];
const client = new SnowLumaWebSocketClient({
url: 'ws://127.0.0.1:3001/',
webSocket: MockWebSocket,
});
const handler = vi.fn();
client.command('ping', handler);
const connecting = client.connect();
const socket = MockWebSocket.instances[0]!;
socket.open();
await connecting;
socket.message(JSON.stringify(PRIVATE_EVENT));
await vi.waitFor(() => {
expect(handler).toHaveBeenCalledTimes(1);
expect(handler.mock.calls[0]![2]).toMatchObject({ args: ['a', 'b'] });
});
});
it('supports caller AbortSignal cancellation for pending requests', async () => {
MockWebSocket.instances = [];
const client = new SnowLumaWebSocketClient({
url: 'ws://127.0.0.1:3001/',
webSocket: MockWebSocket,
});
const controller = new AbortController();
const connecting = client.connect();
const socket = MockWebSocket.instances[0]!;
socket.open();
await connecting;
const request = client.getStatus({ signal: controller.signal });
await vi.waitFor(() => expect(socket.sent).toHaveLength(1));
controller.abort();
await expect(request).rejects.toBeInstanceOf(SnowLumaAbortError);
});
it('dispatches bot_status notices to onBotStatus', async () => {
MockWebSocket.instances = [];
const client = new SnowLumaWebSocketClient({
url: 'ws://127.0.0.1:3001/',
webSocket: MockWebSocket,
});
const handler = vi.fn();
client.onBotStatus(handler);
const connecting = client.connect();
const socket = MockWebSocket.instances[0]!;
socket.open();
await connecting;
socket.message(
JSON.stringify({
time: 1_710_000_000,
self_id: 10000,
post_type: 'notice',
notice_type: 'bot_status',
sub_type: 'offline',
user_id: 10000,
}),
);
await vi.waitFor(() => {
expect(handler).toHaveBeenCalledTimes(1);
expect(handler.mock.calls[0]![0]).toMatchObject({
notice_type: 'bot_status',
sub_type: 'offline',
user_id: 10000,
});
});
});
});