Settle queued sends once when a connection fails or closes before opening, and reject further sends while preserving the original failure. Include connection waits in SDK request deadlines without closing shared connections or sending expired actions after a late connection. Refs #665 Refs #666
750 lines
26 KiB
TypeScript
750 lines
26 KiB
TypeScript
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,
|
|
});
|
|
});
|
|
});
|
|
});
|