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.
400 lines
10 KiB
TypeScript
400 lines
10 KiB
TypeScript
// 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();
|
||
});
|
||
});
|