mirror of
https://github.com/Cockatrice/Cockatrice.git
synced 2026-07-06 05:23:56 -07:00
141 lines
No EOL
5.1 KiB
TypeScript
141 lines
No EOL
5.1 KiB
TypeScript
// Connection-lifecycle scenarios. Exercises the full transport handshake
|
|
// from webClient.connect() through onopen, ServerIdentification, and
|
|
// disconnect — with only the browser WebSocket constructor mocked.
|
|
|
|
import { create } from '@bufbuild/protobuf';
|
|
import { describe, expect, it } from 'vitest';
|
|
|
|
import { Data } from '@app/types';
|
|
import { store } from '@app/store';
|
|
import { WebsocketTypes } from '@app/websocket/types';
|
|
|
|
import {
|
|
getMockWebSocket,
|
|
getWebClient,
|
|
openMockWebSocket,
|
|
setPendingOptions,
|
|
connectAndHandshake,
|
|
PROTOCOL_VERSION,
|
|
} from '../helpers/setup';
|
|
import {
|
|
buildSessionEventMessage,
|
|
deliverMessage,
|
|
} from '../helpers/protobuf-builders';
|
|
import { findLastSessionCommand } from '../helpers/command-capture';
|
|
|
|
function loginOptions(overrides: Partial<{ userName: string; password: string }> = {}): WebsocketTypes.WebSocketConnectOptions {
|
|
return {
|
|
reason: WebsocketTypes.WebSocketConnectReason.LOGIN,
|
|
host: 'localhost',
|
|
port: '4748',
|
|
userName: overrides.userName ?? 'alice',
|
|
password: overrides.password ?? 'secret',
|
|
};
|
|
}
|
|
|
|
function connectWithOptions(opts: WebsocketTypes.WebSocketConnectOptions): void {
|
|
setPendingOptions(opts);
|
|
getWebClient().connect({ host: opts.host, port: opts.port });
|
|
}
|
|
|
|
function serverIdentification(
|
|
protocolVersion = PROTOCOL_VERSION,
|
|
serverName = 'TestServer',
|
|
serverVersion = '2.8.0'
|
|
): Uint8Array {
|
|
const payload = create(Data.Event_ServerIdentificationSchema, {
|
|
serverName,
|
|
serverVersion,
|
|
protocolVersion,
|
|
serverOptions: Data.Event_ServerIdentification_ServerOptions.NoOptions,
|
|
});
|
|
return buildSessionEventMessage(Data.Event_ServerIdentification_ext, payload);
|
|
}
|
|
|
|
describe('connection lifecycle', () => {
|
|
it('flips status through CONNECTING → CONNECTED on socket open', () => {
|
|
connectWithOptions(loginOptions());
|
|
|
|
expect(store.getState().server.status.connectionAttemptMade).toBe(true);
|
|
|
|
openMockWebSocket();
|
|
|
|
expect(store.getState().server.status.state).toBe(WebsocketTypes.StatusEnum.CONNECTED);
|
|
expect(store.getState().server.status.description).toBe('Connected');
|
|
});
|
|
|
|
it('routes a matching ServerIdentification into LOGGING_IN and sends Command_Login', () => {
|
|
connectWithOptions(loginOptions({ userName: 'alice' }));
|
|
openMockWebSocket();
|
|
|
|
deliverMessage(serverIdentification());
|
|
|
|
expect(store.getState().server.status.state).toBe(WebsocketTypes.StatusEnum.LOGGING_IN);
|
|
expect(store.getState().server.info.name).toBe('TestServer');
|
|
expect(store.getState().server.info.version).toBe('2.8.0');
|
|
|
|
const { value, cmdId } = findLastSessionCommand(Data.Command_Login_ext);
|
|
expect(value.userName).toBe('alice');
|
|
expect(cmdId).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('disconnects on protocol version mismatch without sending a login command', () => {
|
|
connectWithOptions(loginOptions());
|
|
openMockWebSocket();
|
|
|
|
deliverMessage(serverIdentification(PROTOCOL_VERSION + 1));
|
|
|
|
const mock = getMockWebSocket();
|
|
expect(mock.close).toHaveBeenCalled();
|
|
expect(store.getState().server.status.state).toBe(WebsocketTypes.StatusEnum.DISCONNECTED);
|
|
expect(() => findLastSessionCommand(Data.Command_Login_ext)).toThrow();
|
|
});
|
|
|
|
it('times out when onopen never fires within the keepalive window', () => {
|
|
connectWithOptions(loginOptions());
|
|
|
|
const mock = getMockWebSocket();
|
|
expect(mock.close).not.toHaveBeenCalled();
|
|
|
|
vi.advanceTimersByTime(5000);
|
|
|
|
// Fire onclose the way a real browser would when the connection-attempt
|
|
// timer closes a still-connecting socket.
|
|
mock.onclose?.({ code: 1006, reason: '', wasClean: false } as CloseEvent);
|
|
|
|
expect(mock.close).toHaveBeenCalled();
|
|
// Never-opened sockets bypass reconnect and land on DISCONNECTED directly.
|
|
expect(store.getState().server.status.state).toBe(WebsocketTypes.StatusEnum.DISCONNECTED);
|
|
});
|
|
|
|
it('releases keep-alive ping loop on explicit disconnect', () => {
|
|
connectWithOptions(loginOptions());
|
|
openMockWebSocket();
|
|
deliverMessage(serverIdentification());
|
|
|
|
const mock = getMockWebSocket();
|
|
getWebClient().disconnect();
|
|
// The transport schedules close() synchronously; onclose follows in the
|
|
// browser event loop. Simulate it so the status transition fires.
|
|
mock.onclose?.({ code: 1000, reason: '', wasClean: true } as CloseEvent);
|
|
|
|
expect(mock.close).toHaveBeenCalled();
|
|
expect(store.getState().server.status.state).toBe(WebsocketTypes.StatusEnum.DISCONNECTED);
|
|
});
|
|
|
|
it('enters RECONNECTING on unexpected socket close after a successful handshake', () => {
|
|
connectAndHandshake();
|
|
|
|
// A login command is now pending (sent during handshake)
|
|
expect(() => findLastSessionCommand(Data.Command_Login_ext)).not.toThrow();
|
|
|
|
// Simulate unexpected socket close
|
|
const mock = getMockWebSocket();
|
|
mock.readyState = 3;
|
|
mock.onclose?.({ code: 1006, reason: '', wasClean: false } as CloseEvent);
|
|
|
|
// With reconnect configured, a drop after a successful open enters the
|
|
// reconnect state machine rather than going straight to DISCONNECTED.
|
|
expect(store.getState().server.status.state).toBe(WebsocketTypes.StatusEnum.RECONNECTING);
|
|
});
|
|
}); |