Files
SEQUA-Engine/studio/ui/tests/websocket.test.ts
T
Ange-Marie MAURIN - A2M Systèmes e231e643cd refactor(studio): l'interface passe dans studio/ui, studio/ devient l'ensemble host + ui
Studio regroupe l'exécutable hôte (C++, à venir dans studio/host) et l'interface web (TypeScript, studio/ui). Chemins mis à jour dans les scripts, .gitignore et la documentation ; paquet renommé @sequa/studio-ui.
2026-11-06 22:24:27 +01:00

400 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// SPDX-License-Identifier: MPL-2.0
import { describe, expect, it, vi } from 'vitest';
import { PROTOCOL_VERSION } from '../src/model/state';
import { decode, encode } from '../src/protocol/messages';
import type { StudioMessage } from '../src/protocol/messages';
import type {
WebSocketCloseEvent,
WebSocketLike,
WebSocketMessageEvent,
} from '../src/protocol/websocket';
import { WebSocketCoreConnection } from '../src/protocol/websocket';
const CORE_URL = 'ws://core.test:9000';
const HELLO: StudioMessage = {
type: 'hello',
payload: { protocolVersion: PROTOCOL_VERSION, client: 'sequa-studio' },
};
const WELCOME: StudioMessage = {
type: 'welcome',
payload: { protocolVersion: PROTOCOL_VERSION, coreVersion: '1.2.3' },
};
const ERROR: StudioMessage = {
type: 'error',
payload: { code: 'auth', message: 'Accès refusé.' },
};
class FakeSocket implements WebSocketLike {
public readonly url: string;
public readyState = 0;
public readonly sent: string[] = [];
public closed = false;
public onopen: ((event: unknown) => void) | null = null;
public onmessage: ((event: WebSocketMessageEvent) => void) | null = null;
public onclose: ((event: WebSocketCloseEvent) => void) | null = null;
public onerror: ((event: unknown) => void) | null = null;
public constructor(url: string) {
this.url = url;
}
public send(data: string): void {
if (this.readyState !== 1) {
throw new Error('send sur un socket non ouvert');
}
this.sent.push(data);
}
public close(): void {
this.closed = true;
this.readyState = 3;
}
public open(): void {
this.readyState = 1;
this.onopen?.({});
}
public receive(data: unknown): void {
this.onmessage?.({ data });
}
public serverClose(reason?: string): void {
this.readyState = 3;
if (reason === undefined) {
this.onclose?.({ code: 1006 });
} else {
this.onclose?.({ code: 1006, reason });
}
}
}
function setup(options: { readonly handshakeTimeoutMs?: number } = {}) {
const sockets: FakeSocket[] = [];
const connection = new WebSocketCoreConnection(CORE_URL, {
...options,
createSocket: (url) => {
const socket = new FakeSocket(url);
sockets.push(socket);
return socket;
},
});
const listener = vi.fn();
const unsubscribe = connection.onEvent(listener);
const socketAt = (index: number): FakeSocket => {
const socket = sockets[index];
if (socket === undefined) {
throw new Error(`Socket ${String(index)} absent.`);
}
return socket;
};
return { connection, listener, unsubscribe, socketAt };
}
function connectAndWelcome(): ReturnType<typeof setup> & { socket: FakeSocket } {
const context = setup();
context.connection.connect();
const socket = context.socketAt(0);
socket.open();
socket.receive(encode(WELCOME));
return { ...context, socket };
}
function sentHello(socket: FakeSocket): StudioMessage {
const text = socket.sent[0];
if (text === undefined) {
throw new Error('Aucun message hello envoyé.');
}
const result = decode(text);
if (!result.ok) {
throw new Error(`hello illisible : ${result.error.message}`);
}
return result.message;
}
function lastReason(listener: ReturnType<typeof vi.fn>): string {
const lastCall = listener.mock.lastCall;
if (lastCall === undefined) {
throw new Error('Aucun événement reçu.');
}
const event: unknown = lastCall[0];
if (typeof event !== 'object' || event === null || !('reason' in event)) {
throw new Error('Le dernier événement ne porte pas de raison.');
}
const reason: unknown = (event as { reason: unknown }).reason;
if (typeof reason !== 'string') {
throw new Error('La raison du dernier événement n’est pas une chaîne.');
}
return reason;
}
describe('WebSocketCoreConnection', () => {
it('se présente comme une connexion réelle au Core', () => {
const { connection } = setup();
expect(connection.simulated).toBe(false);
expect(connection.label).toBe(`Core ${CORE_URL}`);
});
it('émet connecting, envoie hello puis émet connected après welcome', () => {
const { connection, listener, socketAt } = setup();
connection.connect();
expect(listener).toHaveBeenNthCalledWith(1, { type: 'core/connecting' });
const socket = socketAt(0);
expect(socket.url).toBe(CORE_URL);
socket.open();
expect(sentHello(socket)).toEqual(HELLO);
socket.receive(encode(WELCOME));
expect(listener).toHaveBeenLastCalledWith({
type: 'core/connected',
coreVersion: '1.2.3',
});
expect(listener).toHaveBeenCalledTimes(2);
expect(socket.closed).toBe(false);
});
it('refuse une version de protocole incompatible et ferme le socket', () => {
const { connection, listener, socketAt } = setup();
connection.connect();
const socket = socketAt(0);
socket.open();
socket.receive(
encode({
type: 'welcome',
payload: { protocolVersion: '1.0.0', coreVersion: '9.0.0' },
}),
);
expect(socket.closed).toBe(true);
expect(listener).toHaveBeenCalledTimes(2);
expect(lastReason(listener)).toContain('incompatible');
expect(listener).not.toHaveBeenCalledWith(
expect.objectContaining({ type: 'core/connected' }),
);
});
it('traite un message error comme un échec de poignée de main', () => {
const { connection, listener, socketAt } = setup();
connection.connect();
const socket = socketAt(0);
socket.open();
socket.receive(encode(ERROR));
socket.serverClose('fermeture tardive');
expect(socket.closed).toBe(true);
expect(listener).toHaveBeenCalledTimes(2);
expect(lastReason(listener)).toContain('auth');
expect(lastReason(listener)).toContain('Accès refusé.');
});
it('traite un message invalide comme un échec de poignée de main', () => {
const { connection, listener, socketAt } = setup();
connection.connect();
const socket = socketAt(0);
socket.open();
socket.receive('{ ceci n’est pas du JSON');
expect(socket.closed).toBe(true);
expect(listener).toHaveBeenCalledTimes(2);
expect(lastReason(listener)).toContain('illisible');
});
it('rejette un message non textuel pendant la poignée de main', () => {
const { connection, listener, socketAt } = setup();
connection.connect();
const socket = socketAt(0);
socket.open();
socket.receive(42);
expect(socket.closed).toBe(true);
expect(lastReason(listener)).toContain('non textuel');
});
it('émet disconnected avec la raison d’une fermeture distante', () => {
const { listener, socket } = connectAndWelcome();
listener.mockClear();
socket.serverClose('redémarrage du Core');
expect(listener).toHaveBeenCalledTimes(1);
expect(listener).toHaveBeenCalledWith({
type: 'core/disconnected',
reason: 'redémarrage du Core',
});
});
it('fournit une raison lorsque la fermeture distante n’en donne pas', () => {
const { listener, socket } = connectAndWelcome();
listener.mockClear();
socket.serverClose();
expect(listener).toHaveBeenCalledWith({
type: 'core/disconnected',
reason: expect.stringContaining('code 1006') as string,
});
});
it('est idempotent à la déconnexion', () => {
const { connection, listener, socket } = connectAndWelcome();
listener.mockClear();
connection.disconnect();
expect(socket.closed).toBe(true);
expect(listener).toHaveBeenCalledTimes(1);
expect(listener).toHaveBeenCalledWith({ type: 'core/disconnected' });
connection.disconnect();
expect(listener).toHaveBeenCalledTimes(1);
});
it('signale l’échec de création du socket', () => {
const connection = new WebSocketCoreConnection(CORE_URL, {
createSocket: () => {
throw new Error('adresse refusée');
},
});
const listener = vi.fn();
connection.onEvent(listener);
connection.connect();
expect(listener).toHaveBeenCalledTimes(2);
expect(listener).toHaveBeenLastCalledWith({
type: 'core/disconnected',
reason: expect.stringContaining('adresse refusée') as string,
});
});
it('échoue lorsque la poignée de main dépasse le délai par défaut', () => {
vi.useFakeTimers();
try {
const { connection, listener, socketAt } = setup();
connection.connect();
const socket = socketAt(0);
vi.advanceTimersByTime(4999);
expect(listener).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(1);
expect(listener).toHaveBeenCalledTimes(2);
expect(listener).toHaveBeenLastCalledWith({
type: 'core/disconnected',
reason: 'Délai de poignée de main dépassé (5000 ms).',
});
expect(socket.closed).toBe(true);
} finally {
vi.useRealTimers();
}
});
it('respecte la durée de poignée de main configurée', () => {
vi.useFakeTimers();
try {
const { connection, listener } = setup({ handshakeTimeoutMs: 250 });
connection.connect();
vi.advanceTimersByTime(250);
expect(listener).toHaveBeenLastCalledWith({
type: 'core/disconnected',
reason: 'Délai de poignée de main dépassé (250 ms).',
});
} finally {
vi.useRealTimers();
}
});
it('n’expire pas après un welcome valide', () => {
vi.useFakeTimers();
try {
const { listener, socket } = connectAndWelcome();
vi.advanceTimersByTime(60_000);
expect(listener).toHaveBeenCalledTimes(2);
expect(listener).not.toHaveBeenCalledWith(
expect.objectContaining({ type: 'core/disconnected' }),
);
expect(socket.closed).toBe(false);
} finally {
vi.useRealTimers();
}
});
it('annule le minuteur à la déconnexion', () => {
vi.useFakeTimers();
try {
const { connection, listener, socketAt } = setup();
connection.connect();
const socket = socketAt(0);
socket.open();
connection.disconnect();
expect(listener).toHaveBeenCalledTimes(2);
vi.advanceTimersByTime(60_000);
expect(listener).toHaveBeenCalledTimes(2);
expect(socket.closed).toBe(true);
} finally {
vi.useRealTimers();
}
});
it('cesse d’émettre après désabonnement', () => {
const { connection, listener, unsubscribe, socketAt } = setup();
unsubscribe();
connection.connect();
const socket = socketAt(0);
socket.open();
socket.receive(encode(WELCOME));
socket.serverClose('fermeture');
expect(listener).not.toHaveBeenCalled();
});
});