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,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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user