mirror of
https://github.com/Cockatrice/Cockatrice.git
synced 2026-04-27 07:48:01 -07:00
integration tests
This commit is contained in:
parent
0ff391491d
commit
4f68190a25
13 changed files with 1149 additions and 0 deletions
136
webclient/integration/src/authentication.spec.ts
Normal file
136
webclient/integration/src/authentication.spec.ts
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
// Authentication scenarios — login success/failure, register, and activate.
|
||||
|
||||
import { create } from '@bufbuild/protobuf';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { App, Data } from '@app/types';
|
||||
import { store } from '@app/store';
|
||||
|
||||
import { connectAndHandshake } from './helpers/setup';
|
||||
import {
|
||||
buildResponse,
|
||||
buildResponseMessage,
|
||||
deliverMessage,
|
||||
} from './helpers/protobuf-builders';
|
||||
import { findLastSessionCommand } from './helpers/command-capture';
|
||||
|
||||
function makeUser(name: string): Data.ServerInfo_User {
|
||||
return create(Data.ServerInfo_UserSchema, {
|
||||
name,
|
||||
userLevel: Data.ServerInfo_User_UserLevelFlag.IsRegistered,
|
||||
});
|
||||
}
|
||||
|
||||
describe('authentication', () => {
|
||||
describe('login', () => {
|
||||
it('drives LOGIN → LOGGED_IN and populates user info + buddy/ignore lists', () => {
|
||||
connectAndHandshake({ userName: 'alice' });
|
||||
|
||||
const { cmdId, value } = findLastSessionCommand(Data.Command_Login_ext);
|
||||
expect(value.userName).toBe('alice');
|
||||
|
||||
const loginPayload = create(Data.Response_LoginSchema, {
|
||||
userInfo: makeUser('alice'),
|
||||
buddyList: [makeUser('bob')],
|
||||
ignoreList: [makeUser('mallory')],
|
||||
});
|
||||
deliverMessage(buildResponseMessage(buildResponse({
|
||||
cmdId,
|
||||
responseCode: Data.Response_ResponseCode.RespOk,
|
||||
ext: Data.Response_Login_ext,
|
||||
value: loginPayload,
|
||||
})));
|
||||
|
||||
const state = store.getState().server;
|
||||
expect(state.status.state).toBe(App.StatusEnum.LOGGED_IN);
|
||||
expect(state.status.description).toBe('Logged in.');
|
||||
expect(state.user?.name).toBe('alice');
|
||||
expect(Object.keys(state.buddyList)).toEqual(['bob']);
|
||||
expect(Object.keys(state.ignoreList)).toEqual(['mallory']);
|
||||
|
||||
expect(() => findLastSessionCommand(Data.Command_ListUsers_ext)).not.toThrow();
|
||||
expect(() => findLastSessionCommand(Data.Command_ListRooms_ext)).not.toThrow();
|
||||
});
|
||||
|
||||
it('flips status to DISCONNECTED on RespWrongPassword', () => {
|
||||
connectAndHandshake();
|
||||
|
||||
const { cmdId } = findLastSessionCommand(Data.Command_Login_ext);
|
||||
deliverMessage(buildResponseMessage(buildResponse({
|
||||
cmdId,
|
||||
responseCode: Data.Response_ResponseCode.RespWrongPassword,
|
||||
})));
|
||||
|
||||
const state = store.getState().server;
|
||||
expect(state.status.state).toBe(App.StatusEnum.DISCONNECTED);
|
||||
expect(state.user).toBeNull();
|
||||
expect(state.buddyList).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('register', () => {
|
||||
const registerOptions = {
|
||||
reason: App.WebSocketConnectReason.REGISTER,
|
||||
host: 'localhost',
|
||||
port: '4748',
|
||||
userName: 'newbie',
|
||||
password: 'hunter2',
|
||||
email: 'newbie@example.com',
|
||||
country: 'US',
|
||||
realName: 'New Bie',
|
||||
} as const;
|
||||
|
||||
it('auto-logs-in on RespRegistrationAccepted', () => {
|
||||
connectAndHandshake(registerOptions as any);
|
||||
|
||||
const register = findLastSessionCommand(Data.Command_Register_ext);
|
||||
expect(register.value.userName).toBe('newbie');
|
||||
|
||||
deliverMessage(buildResponseMessage(buildResponse({
|
||||
cmdId: register.cmdId,
|
||||
responseCode: Data.Response_ResponseCode.RespRegistrationAccepted,
|
||||
})));
|
||||
|
||||
const login = findLastSessionCommand(Data.Command_Login_ext);
|
||||
expect(login.value.userName).toBe('newbie');
|
||||
expect(login.cmdId).toBeGreaterThan(register.cmdId);
|
||||
});
|
||||
|
||||
it('parks registration in awaiting-activation on RespRegistrationAcceptedNeedsActivation', () => {
|
||||
connectAndHandshake(registerOptions as any);
|
||||
|
||||
const register = findLastSessionCommand(Data.Command_Register_ext);
|
||||
deliverMessage(buildResponseMessage(buildResponse({
|
||||
cmdId: register.cmdId,
|
||||
responseCode: Data.Response_ResponseCode.RespRegistrationAcceptedNeedsActivation,
|
||||
})));
|
||||
|
||||
expect(store.getState().server.status.state).toBe(App.StatusEnum.DISCONNECTED);
|
||||
expect(() => findLastSessionCommand(Data.Command_Login_ext)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('activate', () => {
|
||||
it('auto-logs-in on RespActivationAccepted', () => {
|
||||
connectAndHandshake({
|
||||
reason: App.WebSocketConnectReason.ACTIVATE_ACCOUNT,
|
||||
host: 'localhost',
|
||||
port: '4748',
|
||||
userName: 'alice',
|
||||
token: 'abc-123',
|
||||
password: 'secret',
|
||||
} as any);
|
||||
|
||||
const activate = findLastSessionCommand(Data.Command_Activate_ext);
|
||||
expect(activate.value.userName).toBe('alice');
|
||||
|
||||
deliverMessage(buildResponseMessage(buildResponse({
|
||||
cmdId: activate.cmdId,
|
||||
responseCode: Data.Response_ResponseCode.RespActivationAccepted,
|
||||
})));
|
||||
|
||||
const login = findLastSessionCommand(Data.Command_Login_ext);
|
||||
expect(login.value.userName).toBe('alice');
|
||||
});
|
||||
});
|
||||
});
|
||||
108
webclient/integration/src/connection.spec.ts
Normal file
108
webclient/integration/src/connection.spec.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
// 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 { App, Data } from '@app/types';
|
||||
import { store } from '@app/store';
|
||||
|
||||
import { PROTOCOL_VERSION } from '../../src/websocket/config';
|
||||
|
||||
import { getMockWebSocket, getWebClient, openMockWebSocket } from './helpers/setup';
|
||||
import {
|
||||
buildSessionEventMessage,
|
||||
deliverMessage,
|
||||
} from './helpers/protobuf-builders';
|
||||
import { findLastSessionCommand } from './helpers/command-capture';
|
||||
|
||||
function loginOptions(overrides: Partial<{ userName: string; password: string }> = {}) {
|
||||
return {
|
||||
reason: App.WebSocketConnectReason.LOGIN,
|
||||
host: 'localhost',
|
||||
port: '4748',
|
||||
userName: overrides.userName ?? 'alice',
|
||||
password: overrides.password ?? 'secret',
|
||||
} as const;
|
||||
}
|
||||
|
||||
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', () => {
|
||||
getWebClient().connect(loginOptions());
|
||||
|
||||
expect(store.getState().server.status.connectionAttemptMade).toBe(true);
|
||||
|
||||
openMockWebSocket();
|
||||
|
||||
expect(store.getState().server.status.state).toBe(App.StatusEnum.CONNECTED);
|
||||
expect(store.getState().server.status.description).toBe('Connected');
|
||||
});
|
||||
|
||||
it('routes a matching ServerIdentification into LOGGING_IN and sends Command_Login', () => {
|
||||
getWebClient().connect(loginOptions({ userName: 'alice' }));
|
||||
openMockWebSocket();
|
||||
|
||||
deliverMessage(serverIdentification());
|
||||
|
||||
expect(store.getState().server.status.state).toBe(App.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);
|
||||
|
||||
expect(getWebClient().options).toBeNull();
|
||||
});
|
||||
|
||||
it('disconnects on protocol version mismatch without sending a login command', () => {
|
||||
getWebClient().connect(loginOptions());
|
||||
openMockWebSocket();
|
||||
|
||||
deliverMessage(serverIdentification(PROTOCOL_VERSION + 1));
|
||||
|
||||
const mock = getMockWebSocket();
|
||||
expect(mock.close).toHaveBeenCalled();
|
||||
expect(store.getState().server.status.state).toBe(App.StatusEnum.DISCONNECTED);
|
||||
expect(() => findLastSessionCommand(Data.Command_Login_ext)).toThrow();
|
||||
});
|
||||
|
||||
it('times out when onopen never fires within the keepalive window', () => {
|
||||
getWebClient().connect(loginOptions());
|
||||
|
||||
const mock = getMockWebSocket();
|
||||
expect(mock.close).not.toHaveBeenCalled();
|
||||
|
||||
vi.advanceTimersByTime(5000);
|
||||
|
||||
expect(mock.close).toHaveBeenCalled();
|
||||
expect(store.getState().server.status.state).toBe(App.StatusEnum.DISCONNECTED);
|
||||
});
|
||||
|
||||
it('releases keep-alive ping loop on explicit disconnect', () => {
|
||||
getWebClient().connect(loginOptions());
|
||||
openMockWebSocket();
|
||||
deliverMessage(serverIdentification());
|
||||
|
||||
const mock = getMockWebSocket();
|
||||
getWebClient().disconnect();
|
||||
|
||||
expect(mock.close).toHaveBeenCalled();
|
||||
expect(store.getState().server.status.state).toBe(App.StatusEnum.DISCONNECTED);
|
||||
});
|
||||
});
|
||||
112
webclient/integration/src/helpers/command-capture.ts
Normal file
112
webclient/integration/src/helpers/command-capture.ts
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
// Helpers for inspecting outbound commands. WebSocketService calls
|
||||
// `this.socket.send(bytes)` with the encoded CommandContainer; the mock
|
||||
// WebSocket records those calls on its `send` vi.fn. These helpers decode
|
||||
// the bytes back into a CommandContainer so tests can assert on what was
|
||||
// sent and extract the `cmdId` needed to build a correlated response.
|
||||
|
||||
import { fromBinary, getExtension, hasExtension } from '@bufbuild/protobuf';
|
||||
import type { GenExtension } from '@bufbuild/protobuf/codegenv2';
|
||||
|
||||
import { Data } from '@app/types';
|
||||
|
||||
import { getMockWebSocket } from './setup';
|
||||
|
||||
/** The three command scopes a CommandContainer can carry in practice. */
|
||||
type SessionCmd = Data.SessionCommand;
|
||||
type RoomCmd = Data.RoomCommand;
|
||||
type GameCmd = Data.GameCommand;
|
||||
|
||||
/** Decode every CommandContainer sent through the mock socket so far. */
|
||||
export function captureAllOutbound(): Data.CommandContainer[] {
|
||||
const mock = getMockWebSocket();
|
||||
return mock.send.mock.calls.map(([bytes]: [Uint8Array]) =>
|
||||
fromBinary(Data.CommandContainerSchema, bytes)
|
||||
);
|
||||
}
|
||||
|
||||
/** Decode the most recent CommandContainer. Throws if none has been sent. */
|
||||
export function captureLastOutbound(): Data.CommandContainer {
|
||||
const all = captureAllOutbound();
|
||||
if (all.length === 0) {
|
||||
throw new Error('No outbound command has been sent through the mock WebSocket.');
|
||||
}
|
||||
return all[all.length - 1];
|
||||
}
|
||||
|
||||
/** Numeric cmdId of the most recently sent command (the BigInt cast back to number). */
|
||||
export function lastCmdId(): number {
|
||||
return Number(captureLastOutbound().cmdId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the most recently sent CommandContainer whose session-scope command
|
||||
* carries the given extension, and return both the container and the
|
||||
* unwrapped extension value. Handy for "the login() call fired — grab its
|
||||
* cmdId and the Command_Login payload it sent".
|
||||
*/
|
||||
export function findLastSessionCommand<V>(
|
||||
ext: GenExtension<SessionCmd, V>
|
||||
): { container: Data.CommandContainer; value: V; cmdId: number } {
|
||||
const containers = captureAllOutbound();
|
||||
for (let i = containers.length - 1; i >= 0; i--) {
|
||||
const container = containers[i];
|
||||
for (const sessionCmd of container.sessionCommand ?? []) {
|
||||
if (hasExtension(sessionCmd, ext)) {
|
||||
return {
|
||||
container,
|
||||
value: getExtension(sessionCmd, ext),
|
||||
cmdId: Number(container.cmdId),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new Error(
|
||||
`No outbound session command with extension ${ext.typeName} has been sent.`
|
||||
);
|
||||
}
|
||||
|
||||
/** Room-scoped equivalent of {@link findLastSessionCommand}. */
|
||||
export function findLastRoomCommand<V>(
|
||||
ext: GenExtension<RoomCmd, V>
|
||||
): { container: Data.CommandContainer; value: V; cmdId: number; roomId: number } {
|
||||
const containers = captureAllOutbound();
|
||||
for (let i = containers.length - 1; i >= 0; i--) {
|
||||
const container = containers[i];
|
||||
for (const roomCmd of container.roomCommand ?? []) {
|
||||
if (hasExtension(roomCmd, ext)) {
|
||||
return {
|
||||
container,
|
||||
value: getExtension(roomCmd, ext),
|
||||
cmdId: Number(container.cmdId),
|
||||
roomId: container.roomId ?? 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new Error(
|
||||
`No outbound room command with extension ${ext.typeName} has been sent.`
|
||||
);
|
||||
}
|
||||
|
||||
/** Game-scoped equivalent of {@link findLastSessionCommand}. */
|
||||
export function findLastGameCommand<V>(
|
||||
ext: GenExtension<GameCmd, V>
|
||||
): { container: Data.CommandContainer; value: V; cmdId: number; gameId: number } {
|
||||
const containers = captureAllOutbound();
|
||||
for (let i = containers.length - 1; i >= 0; i--) {
|
||||
const container = containers[i];
|
||||
for (const gameCmd of container.gameCommand ?? []) {
|
||||
if (hasExtension(gameCmd, ext)) {
|
||||
return {
|
||||
container,
|
||||
value: getExtension(gameCmd, ext),
|
||||
cmdId: Number(container.cmdId),
|
||||
gameId: container.gameId ?? 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new Error(
|
||||
`No outbound game command with extension ${ext.typeName} has been sent.`
|
||||
);
|
||||
}
|
||||
125
webclient/integration/src/helpers/protobuf-builders.ts
Normal file
125
webclient/integration/src/helpers/protobuf-builders.ts
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
// Factory helpers that build encoded `ServerMessage` binaries for the four
|
||||
// top-level message types the client consumes (RESPONSE, SESSION_EVENT,
|
||||
// ROOM_EVENT, GAME_EVENT_CONTAINER). Tests call these to simulate incoming
|
||||
// server traffic and then hand the resulting bytes to `deliverMessage()`.
|
||||
//
|
||||
// No mocking of `@bufbuild/protobuf` — every builder uses the real `create`/
|
||||
// `setExtension`/`toBinary` path so the bytes that land in ProtobufService
|
||||
// are byte-for-byte identical to what a Servatrice would send.
|
||||
|
||||
import { create, setExtension, toBinary } from '@bufbuild/protobuf';
|
||||
import type { GenExtension, GenMessage } from '@bufbuild/protobuf/codegenv2';
|
||||
import type { MessageInitShape } from '@bufbuild/protobuf';
|
||||
|
||||
import { Data } from '@app/types';
|
||||
|
||||
import { getMockWebSocket } from './setup';
|
||||
|
||||
/**
|
||||
* Convenience wrapper around `create` for schemas that accept an init shape.
|
||||
* Mirrors the pattern used throughout the webclient codebase.
|
||||
*/
|
||||
export function make<S extends GenMessage<any>>(
|
||||
schema: S,
|
||||
init?: MessageInitShape<S>
|
||||
): ReturnType<typeof create<S>> {
|
||||
return create(schema, init);
|
||||
}
|
||||
|
||||
/** Build a top-level ServerMessage wrapping a Response. */
|
||||
export function buildResponseMessage(response: Data.Response): Uint8Array {
|
||||
const msg = create(Data.ServerMessageSchema, {
|
||||
messageType: Data.ServerMessage_MessageType.RESPONSE,
|
||||
response,
|
||||
});
|
||||
return toBinary(Data.ServerMessageSchema, msg);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a Response with an optional response-payload extension attached.
|
||||
* `cmdId` must match the outbound command the test is responding to —
|
||||
* callers typically read it from `captureOutbound()`.
|
||||
*/
|
||||
export function buildResponse<V>(params: {
|
||||
cmdId: number;
|
||||
responseCode?: Data.Response_ResponseCode;
|
||||
ext?: GenExtension<Data.Response, V>;
|
||||
value?: V;
|
||||
}): Data.Response {
|
||||
const response = create(Data.ResponseSchema, {
|
||||
cmdId: BigInt(params.cmdId),
|
||||
responseCode: params.responseCode ?? Data.Response_ResponseCode.RespOk,
|
||||
});
|
||||
if (params.ext && params.value !== undefined) {
|
||||
setExtension(response, params.ext, params.value);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
/** Build a top-level ServerMessage wrapping a SessionEvent with the given extension. */
|
||||
export function buildSessionEventMessage<V>(
|
||||
ext: GenExtension<Data.SessionEvent, V>,
|
||||
value: V
|
||||
): Uint8Array {
|
||||
const sessionEvent = create(Data.SessionEventSchema);
|
||||
setExtension(sessionEvent, ext, value);
|
||||
const msg = create(Data.ServerMessageSchema, {
|
||||
messageType: Data.ServerMessage_MessageType.SESSION_EVENT,
|
||||
sessionEvent,
|
||||
});
|
||||
return toBinary(Data.ServerMessageSchema, msg);
|
||||
}
|
||||
|
||||
/** Build a top-level ServerMessage wrapping a RoomEvent with the given extension. */
|
||||
export function buildRoomEventMessage<V>(
|
||||
roomId: number,
|
||||
ext: GenExtension<Data.RoomEvent, V>,
|
||||
value: V
|
||||
): Uint8Array {
|
||||
const roomEvent = create(Data.RoomEventSchema, { roomId });
|
||||
setExtension(roomEvent, ext, value);
|
||||
const msg = create(Data.ServerMessageSchema, {
|
||||
messageType: Data.ServerMessage_MessageType.ROOM_EVENT,
|
||||
roomEvent,
|
||||
});
|
||||
return toBinary(Data.ServerMessageSchema, msg);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a top-level ServerMessage wrapping a GameEventContainer whose
|
||||
* `eventList` contains a single GameEvent with the given extension attached.
|
||||
*/
|
||||
export function buildGameEventMessage<V>(
|
||||
params: {
|
||||
gameId: number;
|
||||
playerId?: number;
|
||||
ext: GenExtension<Data.GameEvent, V>;
|
||||
value: V;
|
||||
}
|
||||
): Uint8Array {
|
||||
const gameEvent = create(Data.GameEventSchema, {
|
||||
playerId: params.playerId ?? -1,
|
||||
});
|
||||
setExtension(gameEvent, params.ext, params.value);
|
||||
const container = create(Data.GameEventContainerSchema, {
|
||||
gameId: params.gameId,
|
||||
eventList: [gameEvent],
|
||||
});
|
||||
const msg = create(Data.ServerMessageSchema, {
|
||||
messageType: Data.ServerMessage_MessageType.GAME_EVENT_CONTAINER,
|
||||
gameEventContainer: container,
|
||||
});
|
||||
return toBinary(Data.ServerMessageSchema, msg);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliver an encoded ServerMessage to the currently-connected mock socket.
|
||||
* WebSocketService wires `onmessage` to push events into its RxJS subject,
|
||||
* which ProtobufService subscribes to — so this triggers the full inbound
|
||||
* pipeline synchronously.
|
||||
*/
|
||||
export function deliverMessage(binary: Uint8Array): void {
|
||||
const mock = getMockWebSocket();
|
||||
const event = { data: binary.buffer } as MessageEvent;
|
||||
mock.onmessage?.(event);
|
||||
}
|
||||
182
webclient/integration/src/helpers/setup.ts
Normal file
182
webclient/integration/src/helpers/setup.ts
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
// Integration test setup — installs a mock WebSocket constructor, wires up
|
||||
// fake timers for KeepAliveService control, and resets the webClient + Redux
|
||||
// singletons between tests so real event handlers and reducers can run
|
||||
// against a clean slate each time.
|
||||
//
|
||||
// Only `globalThis.WebSocket` is mocked. Everything downstream of it
|
||||
// (ProtobufService, event registries, persistence, store, reducers) runs as
|
||||
// real code, which is the whole point of the integration suite.
|
||||
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
import '../../../src/polyfills';
|
||||
|
||||
import { create } from '@bufbuild/protobuf';
|
||||
import { afterEach, beforeEach, vi } from 'vitest';
|
||||
|
||||
import { ServerDispatch, RoomsDispatch, GameDispatch } from '@app/store';
|
||||
import { App, Data, Enriched } from '@app/types';
|
||||
import { WebClient } from '@app/websocket';
|
||||
import { PROTOCOL_VERSION } from '../../../src/websocket/config';
|
||||
import { createWebClientResponse, createWebClientRequest } from '@app/api';
|
||||
|
||||
import {
|
||||
buildResponse,
|
||||
buildResponseMessage,
|
||||
buildSessionEventMessage,
|
||||
deliverMessage,
|
||||
} from './protobuf-builders';
|
||||
import { findLastSessionCommand } from './command-capture';
|
||||
|
||||
export interface MockWebSocketInstance {
|
||||
send: ReturnType<typeof vi.fn>;
|
||||
close: ReturnType<typeof vi.fn>;
|
||||
readyState: number;
|
||||
binaryType: BinaryType;
|
||||
url: string;
|
||||
onopen: ((ev?: Event) => void) | null;
|
||||
onclose: ((ev?: CloseEvent) => void) | null;
|
||||
onerror: ((ev?: Event) => void) | null;
|
||||
onmessage: ((ev: MessageEvent) => void) | null;
|
||||
}
|
||||
|
||||
let currentMockInstance: MockWebSocketInstance | null = null;
|
||||
|
||||
export function getMockWebSocket(): MockWebSocketInstance {
|
||||
if (!currentMockInstance) {
|
||||
throw new Error(
|
||||
'No mock WebSocket has been constructed yet. Call webClient.connect(...) before reading the mock instance.'
|
||||
);
|
||||
}
|
||||
return currentMockInstance;
|
||||
}
|
||||
|
||||
function makeMockInstance(url: string): MockWebSocketInstance {
|
||||
return {
|
||||
send: vi.fn(),
|
||||
close: vi.fn(function close(this: MockWebSocketInstance) {
|
||||
this.readyState = 3; // CLOSED
|
||||
this.onclose?.({ code: 1000, reason: '', wasClean: true } as CloseEvent);
|
||||
}),
|
||||
readyState: 0, // CONNECTING
|
||||
binaryType: 'arraybuffer',
|
||||
url,
|
||||
onopen: null,
|
||||
onclose: null,
|
||||
onerror: null,
|
||||
onmessage: null,
|
||||
};
|
||||
}
|
||||
|
||||
function installMockWebSocket(): void {
|
||||
const MockWS = vi.fn(function MockWebSocket(url: string) {
|
||||
currentMockInstance = makeMockInstance(url);
|
||||
return currentMockInstance;
|
||||
}) as unknown as typeof WebSocket;
|
||||
(MockWS as unknown as { CONNECTING: number }).CONNECTING = 0;
|
||||
(MockWS as unknown as { OPEN: number }).OPEN = 1;
|
||||
(MockWS as unknown as { CLOSING: number }).CLOSING = 2;
|
||||
(MockWS as unknown as { CLOSED: number }).CLOSED = 3;
|
||||
globalThis.WebSocket = MockWS;
|
||||
}
|
||||
|
||||
export function openMockWebSocket(): void {
|
||||
const mock = getMockWebSocket();
|
||||
mock.readyState = 1; // OPEN
|
||||
mock.onopen?.(new Event('open'));
|
||||
}
|
||||
|
||||
export function getWebClient(): WebClient {
|
||||
return WebClient.instance;
|
||||
}
|
||||
|
||||
function resetAll(): void {
|
||||
const client = WebClient.instance;
|
||||
|
||||
if (currentMockInstance && currentMockInstance.readyState === 1) {
|
||||
client.disconnect();
|
||||
}
|
||||
|
||||
client.protobuf.resetCommands();
|
||||
client.options = null;
|
||||
client.status = App.StatusEnum.DISCONNECTED;
|
||||
|
||||
ServerDispatch.clearStore();
|
||||
RoomsDispatch.clearStore();
|
||||
GameDispatch.clearStore();
|
||||
|
||||
if (currentMockInstance) {
|
||||
currentMockInstance.onopen = null;
|
||||
currentMockInstance.onclose = null;
|
||||
currentMockInstance.onerror = null;
|
||||
currentMockInstance.onmessage = null;
|
||||
currentMockInstance = null;
|
||||
}
|
||||
|
||||
(WebClient as unknown as { _instance: WebClient | null })._instance = null;
|
||||
}
|
||||
|
||||
// ── Shared connect helpers ──────────────────────────────────────────────────
|
||||
|
||||
const DEFAULT_LOGIN_OPTIONS: Enriched.LoginConnectOptions = {
|
||||
reason: App.WebSocketConnectReason.LOGIN,
|
||||
host: 'localhost',
|
||||
port: '4748',
|
||||
userName: 'alice',
|
||||
password: 'secret',
|
||||
};
|
||||
|
||||
export function connectRaw(
|
||||
overrides: Partial<Enriched.LoginConnectOptions> = {}
|
||||
): void {
|
||||
getWebClient().connect({ ...DEFAULT_LOGIN_OPTIONS, ...overrides });
|
||||
openMockWebSocket();
|
||||
}
|
||||
|
||||
export function connectAndHandshake(
|
||||
overrides: Partial<Enriched.LoginConnectOptions> = {}
|
||||
): void {
|
||||
connectRaw(overrides);
|
||||
deliverMessage(buildSessionEventMessage(
|
||||
Data.Event_ServerIdentification_ext,
|
||||
create(Data.Event_ServerIdentificationSchema, {
|
||||
serverName: 'TestServer',
|
||||
serverVersion: '2.8.0',
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
})
|
||||
));
|
||||
}
|
||||
|
||||
export function connectAndLogin(userName: string = 'alice'): void {
|
||||
connectAndHandshake({ userName });
|
||||
|
||||
const login = findLastSessionCommand(Data.Command_Login_ext);
|
||||
const userInfo = create(Data.ServerInfo_UserSchema, {
|
||||
name: userName,
|
||||
userLevel: Data.ServerInfo_User_UserLevelFlag.IsRegistered,
|
||||
});
|
||||
deliverMessage(buildResponseMessage(buildResponse({
|
||||
cmdId: login.cmdId,
|
||||
responseCode: Data.Response_ResponseCode.RespOk,
|
||||
ext: Data.Response_Login_ext,
|
||||
value: create(Data.Response_LoginSchema, {
|
||||
userInfo,
|
||||
buddyList: [],
|
||||
ignoreList: [],
|
||||
}),
|
||||
})));
|
||||
}
|
||||
|
||||
// ── Lifecycle hooks ─────────────────────────────────────────────────────────
|
||||
|
||||
installMockWebSocket();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
new WebClient(createWebClientResponse(), createWebClientRequest());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
resetAll();
|
||||
vi.clearAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
65
webclient/integration/src/keep-alive.spec.ts
Normal file
65
webclient/integration/src/keep-alive.spec.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
// KeepAliveService timing scenarios — ping loop, pong correlation, timeout.
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { App, Data } from '@app/types';
|
||||
import { store } from '@app/store';
|
||||
|
||||
import { connectRaw, getMockWebSocket } from './helpers/setup';
|
||||
import {
|
||||
buildResponse,
|
||||
buildResponseMessage,
|
||||
deliverMessage,
|
||||
} from './helpers/protobuf-builders';
|
||||
import { findLastSessionCommand } from './helpers/command-capture';
|
||||
|
||||
describe('keep-alive', () => {
|
||||
it('sends a Command_Ping on every keepalive interval tick', () => {
|
||||
connectRaw();
|
||||
|
||||
expect(() => findLastSessionCommand(Data.Command_Ping_ext)).toThrow();
|
||||
|
||||
vi.advanceTimersByTime(5000);
|
||||
const first = findLastSessionCommand(Data.Command_Ping_ext);
|
||||
expect(first.cmdId).toBeGreaterThan(0);
|
||||
|
||||
deliverMessage(buildResponseMessage(buildResponse({
|
||||
cmdId: first.cmdId,
|
||||
responseCode: Data.Response_ResponseCode.RespOk,
|
||||
})));
|
||||
|
||||
vi.advanceTimersByTime(5000);
|
||||
const second = findLastSessionCommand(Data.Command_Ping_ext);
|
||||
expect(second.cmdId).toBeGreaterThan(first.cmdId);
|
||||
expect(store.getState().server.status.state).toBe(App.StatusEnum.CONNECTED);
|
||||
});
|
||||
|
||||
it('stays CONNECTED while pongs arrive before the next tick', () => {
|
||||
connectRaw();
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
vi.advanceTimersByTime(5000);
|
||||
const ping = findLastSessionCommand(Data.Command_Ping_ext);
|
||||
deliverMessage(buildResponseMessage(buildResponse({
|
||||
cmdId: ping.cmdId,
|
||||
responseCode: Data.Response_ResponseCode.RespOk,
|
||||
})));
|
||||
}
|
||||
|
||||
expect(store.getState().server.status.state).toBe(App.StatusEnum.CONNECTED);
|
||||
expect(getMockWebSocket().close).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('disconnects with a timeout status when a ping goes unanswered', () => {
|
||||
connectRaw();
|
||||
|
||||
vi.advanceTimersByTime(5000);
|
||||
expect(() => findLastSessionCommand(Data.Command_Ping_ext)).not.toThrow();
|
||||
expect(store.getState().server.status.state).toBe(App.StatusEnum.CONNECTED);
|
||||
|
||||
vi.advanceTimersByTime(5000);
|
||||
|
||||
expect(getMockWebSocket().close).toHaveBeenCalled();
|
||||
expect(store.getState().server.status.state).toBe(App.StatusEnum.DISCONNECTED);
|
||||
});
|
||||
});
|
||||
140
webclient/integration/src/rooms.spec.ts
Normal file
140
webclient/integration/src/rooms.spec.ts
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
// Room scenarios — Event_ListRooms handling, auto-join, Response_JoinRoom,
|
||||
// room chat, and in-room game list updates.
|
||||
|
||||
import { create } from '@bufbuild/protobuf';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { Data } from '@app/types';
|
||||
import { store } from '@app/store';
|
||||
|
||||
import { connectAndHandshake } from './helpers/setup';
|
||||
import {
|
||||
buildResponse,
|
||||
buildResponseMessage,
|
||||
buildRoomEventMessage,
|
||||
buildSessionEventMessage,
|
||||
deliverMessage,
|
||||
} from './helpers/protobuf-builders';
|
||||
import { findLastSessionCommand } from './helpers/command-capture';
|
||||
|
||||
function makeRoom(overrides: Partial<{
|
||||
roomId: number;
|
||||
name: string;
|
||||
autoJoin: boolean;
|
||||
}> = {}): Data.ServerInfo_Room {
|
||||
return create(Data.ServerInfo_RoomSchema, {
|
||||
roomId: overrides.roomId ?? 1,
|
||||
name: overrides.name ?? 'Lobby',
|
||||
description: 'Test room',
|
||||
gameCount: 0,
|
||||
playerCount: 0,
|
||||
autoJoin: overrides.autoJoin ?? false,
|
||||
gameList: [],
|
||||
userList: [],
|
||||
gametypeList: [],
|
||||
});
|
||||
}
|
||||
|
||||
describe('rooms', () => {
|
||||
it('populates rooms state from Event_ListRooms', () => {
|
||||
connectAndHandshake();
|
||||
|
||||
const listRooms = create(Data.Event_ListRoomsSchema, {
|
||||
roomList: [
|
||||
makeRoom({ roomId: 1, name: 'Lobby' }),
|
||||
makeRoom({ roomId: 2, name: 'Legacy' }),
|
||||
],
|
||||
});
|
||||
deliverMessage(buildSessionEventMessage(Data.Event_ListRooms_ext, listRooms));
|
||||
|
||||
const { rooms } = store.getState().rooms;
|
||||
expect(rooms[1]?.info?.name).toBe('Lobby');
|
||||
expect(rooms[2]?.info?.name).toBe('Legacy');
|
||||
});
|
||||
|
||||
it('auto-joins rooms flagged with autoJoin and flips joinedRoomIds on Response_JoinRoom', () => {
|
||||
connectAndHandshake();
|
||||
|
||||
const listRooms = create(Data.Event_ListRoomsSchema, {
|
||||
roomList: [
|
||||
makeRoom({ roomId: 1, name: 'Lobby', autoJoin: true }),
|
||||
makeRoom({ roomId: 2, name: 'Legacy', autoJoin: false }),
|
||||
],
|
||||
});
|
||||
deliverMessage(buildSessionEventMessage(Data.Event_ListRooms_ext, listRooms));
|
||||
|
||||
const join = findLastSessionCommand(Data.Command_JoinRoom_ext);
|
||||
expect(join.value.roomId).toBe(1);
|
||||
|
||||
const joined = create(Data.Response_JoinRoomSchema, {
|
||||
roomInfo: makeRoom({ roomId: 1, name: 'Lobby' }),
|
||||
});
|
||||
deliverMessage(buildResponseMessage(buildResponse({
|
||||
cmdId: join.cmdId,
|
||||
responseCode: Data.Response_ResponseCode.RespOk,
|
||||
ext: Data.Response_JoinRoom_ext,
|
||||
value: joined,
|
||||
})));
|
||||
|
||||
expect(store.getState().rooms.joinedRoomIds[1]).toBe(true);
|
||||
});
|
||||
|
||||
it('appends a room chat message on Event_RoomSay', () => {
|
||||
connectAndHandshake();
|
||||
|
||||
deliverMessage(buildSessionEventMessage(
|
||||
Data.Event_ListRooms_ext,
|
||||
create(Data.Event_ListRoomsSchema, { roomList: [makeRoom({ roomId: 1, autoJoin: true })] })
|
||||
));
|
||||
const join = findLastSessionCommand(Data.Command_JoinRoom_ext);
|
||||
deliverMessage(buildResponseMessage(buildResponse({
|
||||
cmdId: join.cmdId,
|
||||
responseCode: Data.Response_ResponseCode.RespOk,
|
||||
ext: Data.Response_JoinRoom_ext,
|
||||
value: create(Data.Response_JoinRoomSchema, { roomInfo: makeRoom({ roomId: 1 }) }),
|
||||
})));
|
||||
|
||||
const say = create(Data.Event_RoomSaySchema, {
|
||||
name: 'bob',
|
||||
message: 'hello world',
|
||||
messageType: Data.Event_RoomSay_RoomMessageType.UserMessage,
|
||||
});
|
||||
deliverMessage(buildRoomEventMessage(1, Data.Event_RoomSay_ext, say));
|
||||
|
||||
const messages = store.getState().rooms.messages[1];
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages[0].message).toBe('bob: hello world');
|
||||
expect(messages[0].name).toBe('bob');
|
||||
});
|
||||
|
||||
it('updates the game list on Event_ListGames', () => {
|
||||
connectAndHandshake();
|
||||
|
||||
deliverMessage(buildSessionEventMessage(
|
||||
Data.Event_ListRooms_ext,
|
||||
create(Data.Event_ListRoomsSchema, { roomList: [makeRoom({ roomId: 1, autoJoin: true })] })
|
||||
));
|
||||
const join = findLastSessionCommand(Data.Command_JoinRoom_ext);
|
||||
deliverMessage(buildResponseMessage(buildResponse({
|
||||
cmdId: join.cmdId,
|
||||
responseCode: Data.Response_ResponseCode.RespOk,
|
||||
ext: Data.Response_JoinRoom_ext,
|
||||
value: create(Data.Response_JoinRoomSchema, { roomInfo: makeRoom({ roomId: 1 }) }),
|
||||
})));
|
||||
|
||||
const game = create(Data.ServerInfo_GameSchema, {
|
||||
gameId: 42,
|
||||
description: 'Test Game',
|
||||
maxPlayers: 4,
|
||||
playerCount: 1,
|
||||
startTime: 1,
|
||||
});
|
||||
const listGames = create(Data.Event_ListGamesSchema, { gameList: [game] });
|
||||
deliverMessage(buildRoomEventMessage(1, Data.Event_ListGames_ext, listGames));
|
||||
|
||||
const roomGames = store.getState().rooms.rooms[1]?.games;
|
||||
expect(roomGames).toBeDefined();
|
||||
expect(roomGames?.[42]?.info?.description).toBe('Test Game');
|
||||
expect(roomGames?.[42]?.info?.gameId).toBe(42);
|
||||
});
|
||||
});
|
||||
105
webclient/integration/src/server-events.spec.ts
Normal file
105
webclient/integration/src/server-events.spec.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
// Server-level session events — server message banner, shutdown schedule,
|
||||
// user notifications, and connection-closed reason code mapping.
|
||||
|
||||
import { create } from '@bufbuild/protobuf';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { App, Data } from '@app/types';
|
||||
import { store } from '@app/store';
|
||||
|
||||
import { connectAndHandshake } from './helpers/setup';
|
||||
import {
|
||||
buildSessionEventMessage,
|
||||
deliverMessage,
|
||||
} from './helpers/protobuf-builders';
|
||||
|
||||
describe('server events', () => {
|
||||
it('writes the server banner into server.info.message on Event_ServerMessage', () => {
|
||||
connectAndHandshake();
|
||||
|
||||
deliverMessage(buildSessionEventMessage(
|
||||
Data.Event_ServerMessage_ext,
|
||||
create(Data.Event_ServerMessageSchema, { message: 'Welcome to TestServer!' })
|
||||
));
|
||||
|
||||
expect(store.getState().server.info.message).toBe('Welcome to TestServer!');
|
||||
});
|
||||
|
||||
it('stores the shutdown payload on Event_ServerShutdown', () => {
|
||||
connectAndHandshake();
|
||||
|
||||
deliverMessage(buildSessionEventMessage(
|
||||
Data.Event_ServerShutdown_ext,
|
||||
create(Data.Event_ServerShutdownSchema, {
|
||||
reason: 'Scheduled maintenance',
|
||||
minutes: 5,
|
||||
})
|
||||
));
|
||||
|
||||
const shutdown = store.getState().server.serverShutdown;
|
||||
expect(shutdown).not.toBeNull();
|
||||
expect(shutdown?.reason).toBe('Scheduled maintenance');
|
||||
expect(shutdown?.minutes).toBe(5);
|
||||
});
|
||||
|
||||
it('appends a notification on Event_NotifyUser', () => {
|
||||
connectAndHandshake();
|
||||
|
||||
deliverMessage(buildSessionEventMessage(
|
||||
Data.Event_NotifyUser_ext,
|
||||
create(Data.Event_NotifyUserSchema, {
|
||||
type: Data.Event_NotifyUser_NotificationType.PROMOTION,
|
||||
customTitle: 'You have been promoted',
|
||||
customContent: 'Now a judge',
|
||||
})
|
||||
));
|
||||
|
||||
const notifications = store.getState().server.notifications;
|
||||
expect(notifications).toHaveLength(1);
|
||||
expect(notifications[0].customTitle).toBe('You have been promoted');
|
||||
});
|
||||
|
||||
describe('connection closed', () => {
|
||||
it('prefers reasonStr when provided', () => {
|
||||
connectAndHandshake();
|
||||
|
||||
deliverMessage(buildSessionEventMessage(
|
||||
Data.Event_ConnectionClosed_ext,
|
||||
create(Data.Event_ConnectionClosedSchema, {
|
||||
reason: Data.Event_ConnectionClosed_CloseReason.OTHER,
|
||||
reasonStr: 'kicked by admin',
|
||||
})
|
||||
));
|
||||
|
||||
const status = store.getState().server.status;
|
||||
expect(status.state).toBe(App.StatusEnum.DISCONNECTED);
|
||||
expect(status.description).toBe('kicked by admin');
|
||||
});
|
||||
|
||||
it('maps USER_LIMIT_REACHED to a capacity message', () => {
|
||||
connectAndHandshake();
|
||||
|
||||
deliverMessage(buildSessionEventMessage(
|
||||
Data.Event_ConnectionClosed_ext,
|
||||
create(Data.Event_ConnectionClosedSchema, {
|
||||
reason: Data.Event_ConnectionClosed_CloseReason.USER_LIMIT_REACHED,
|
||||
})
|
||||
));
|
||||
|
||||
expect(store.getState().server.status.description).toContain('maximum user capacity');
|
||||
});
|
||||
|
||||
it('maps LOGGEDINELSEWERE to a multi-session message', () => {
|
||||
connectAndHandshake();
|
||||
|
||||
deliverMessage(buildSessionEventMessage(
|
||||
Data.Event_ConnectionClosed_ext,
|
||||
create(Data.Event_ConnectionClosedSchema, {
|
||||
reason: Data.Event_ConnectionClosed_CloseReason.LOGGEDINELSEWERE,
|
||||
})
|
||||
));
|
||||
|
||||
expect(store.getState().server.status.description).toContain('another location');
|
||||
});
|
||||
});
|
||||
});
|
||||
120
webclient/integration/src/users.spec.ts
Normal file
120
webclient/integration/src/users.spec.ts
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
// User-list and social scenarios — user presence, buddy/ignore lists, DMs.
|
||||
|
||||
import { create } from '@bufbuild/protobuf';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { Data } from '@app/types';
|
||||
import { store } from '@app/store';
|
||||
|
||||
import { connectAndLogin } from './helpers/setup';
|
||||
import {
|
||||
buildResponse,
|
||||
buildResponseMessage,
|
||||
buildSessionEventMessage,
|
||||
deliverMessage,
|
||||
} from './helpers/protobuf-builders';
|
||||
import { findLastSessionCommand } from './helpers/command-capture';
|
||||
|
||||
function makeUser(name: string): Data.ServerInfo_User {
|
||||
return create(Data.ServerInfo_UserSchema, {
|
||||
name,
|
||||
userLevel: Data.ServerInfo_User_UserLevelFlag.IsRegistered,
|
||||
});
|
||||
}
|
||||
|
||||
describe('users', () => {
|
||||
it('populates state.users from the Response_ListUsers post-login', () => {
|
||||
connectAndLogin();
|
||||
|
||||
const listUsers = findLastSessionCommand(Data.Command_ListUsers_ext);
|
||||
const users = [makeUser('alice'), makeUser('bob'), makeUser('carol')];
|
||||
deliverMessage(buildResponseMessage(buildResponse({
|
||||
cmdId: listUsers.cmdId,
|
||||
responseCode: Data.Response_ResponseCode.RespOk,
|
||||
ext: Data.Response_ListUsers_ext,
|
||||
value: create(Data.Response_ListUsersSchema, { userList: users }),
|
||||
})));
|
||||
|
||||
expect(Object.keys(store.getState().server.users).sort()).toEqual(['alice', 'bob', 'carol']);
|
||||
});
|
||||
|
||||
it('appends on Event_UserJoined and removes on Event_UserLeft', () => {
|
||||
connectAndLogin();
|
||||
|
||||
deliverMessage(buildSessionEventMessage(
|
||||
Data.Event_UserJoined_ext,
|
||||
create(Data.Event_UserJoinedSchema, { userInfo: makeUser('bob') })
|
||||
));
|
||||
expect('bob' in store.getState().server.users).toBe(true);
|
||||
|
||||
deliverMessage(buildSessionEventMessage(
|
||||
Data.Event_UserLeft_ext,
|
||||
create(Data.Event_UserLeftSchema, { name: 'bob' })
|
||||
));
|
||||
expect('bob' in store.getState().server.users).toBe(false);
|
||||
});
|
||||
|
||||
it('adds a user to buddyList on Event_AddToList with listName=buddy', () => {
|
||||
connectAndLogin();
|
||||
|
||||
deliverMessage(buildSessionEventMessage(
|
||||
Data.Event_AddToList_ext,
|
||||
create(Data.Event_AddToListSchema, {
|
||||
listName: 'buddy',
|
||||
userInfo: makeUser('bob'),
|
||||
})
|
||||
));
|
||||
|
||||
expect('bob' in store.getState().server.buddyList).toBe(true);
|
||||
expect(store.getState().server.ignoreList).toEqual({});
|
||||
});
|
||||
|
||||
it('adds a user to ignoreList on Event_AddToList with listName=ignore', () => {
|
||||
connectAndLogin();
|
||||
|
||||
deliverMessage(buildSessionEventMessage(
|
||||
Data.Event_AddToList_ext,
|
||||
create(Data.Event_AddToListSchema, {
|
||||
listName: 'ignore',
|
||||
userInfo: makeUser('mallory'),
|
||||
})
|
||||
));
|
||||
|
||||
expect('mallory' in store.getState().server.ignoreList).toBe(true);
|
||||
expect(store.getState().server.buddyList).toEqual({});
|
||||
});
|
||||
|
||||
it('files an incoming direct message under the sender', () => {
|
||||
connectAndLogin('alice');
|
||||
|
||||
deliverMessage(buildSessionEventMessage(
|
||||
Data.Event_UserMessage_ext,
|
||||
create(Data.Event_UserMessageSchema, {
|
||||
senderName: 'bob',
|
||||
receiverName: 'alice',
|
||||
message: 'hi alice',
|
||||
})
|
||||
));
|
||||
|
||||
const { messages } = store.getState().server;
|
||||
expect(messages.bob).toHaveLength(1);
|
||||
expect(messages.bob[0].message).toBe('hi alice');
|
||||
});
|
||||
|
||||
it('files an outgoing direct message under the recipient', () => {
|
||||
connectAndLogin('alice');
|
||||
|
||||
deliverMessage(buildSessionEventMessage(
|
||||
Data.Event_UserMessage_ext,
|
||||
create(Data.Event_UserMessageSchema, {
|
||||
senderName: 'alice',
|
||||
receiverName: 'bob',
|
||||
message: 'hey bob',
|
||||
})
|
||||
));
|
||||
|
||||
const { messages } = store.getState().server;
|
||||
expect(messages.bob).toHaveLength(1);
|
||||
expect(messages.bob[0].message).toBe('hey bob');
|
||||
});
|
||||
});
|
||||
7
webclient/integration/tsconfig.json
Normal file
7
webclient/integration/tsconfig.json
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["./**/*.ts"]
|
||||
}
|
||||
|
|
@ -10,6 +10,8 @@
|
|||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:integration": "vitest run --config vitest.integration.config.ts",
|
||||
"test:integration:coverage": "vitest run --config vitest.integration.config.ts --coverage",
|
||||
"lint": "eslint src/",
|
||||
"lint:fix": "eslint src/ --fix",
|
||||
"golden": "npm run lint && npm run test",
|
||||
|
|
|
|||
|
|
@ -19,5 +19,18 @@ export default defineConfig({
|
|||
setupFiles: ['./src/setupTests.ts'],
|
||||
include: ['src/**/*.spec.{ts,tsx}'],
|
||||
isolate: false,
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'html'],
|
||||
reportsDirectory: './coverage/testing',
|
||||
include: ['src/**/*.{ts,tsx}'],
|
||||
exclude: [
|
||||
'src/generated/**',
|
||||
'src/**/*.spec.{ts,tsx}',
|
||||
'src/**/__mocks__/**',
|
||||
'src/setupTests.ts',
|
||||
'src/polyfills.ts',
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
|||
34
webclient/vitest.integration.config.ts
Normal file
34
webclient/vitest.integration.config.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import react from '@vitejs/plugin-react';
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
// Integration tests exercise the full inbound/outbound webclient pipeline
|
||||
// (ProtobufService → event handlers → persistence → Redux) with only the
|
||||
// browser WebSocket constructor mocked. They live in `integration/` and run
|
||||
// under their own config so they can use `isolate: true` without slowing down
|
||||
// the unit suite (which relies on `isolate: false` for shared vi.mock state).
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
tsconfigPaths: true,
|
||||
},
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
setupFiles: ['./integration/src/helpers/setup.ts'],
|
||||
include: ['integration/src/**/*.spec.ts'],
|
||||
isolate: false,
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'html'],
|
||||
reportsDirectory: './coverage/integration',
|
||||
include: ['src/**/*.{ts,tsx}'],
|
||||
exclude: [
|
||||
'src/generated/**',
|
||||
'src/**/*.spec.{ts,tsx}',
|
||||
'src/**/__mocks__/**',
|
||||
'src/setupTests.ts',
|
||||
'src/polyfills.ts',
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue