feat(studio): connexion WebSocket native au Core
Client WebSocket sans bibliothèque derrière CoreConnection, messages JSON provisoires hello/welcome/error validés (en attendant l'ADR du schéma), délai de poignée de main, compatibilité semver stricte en 0.x, choix du Core par ?core=ws://..., Core simulé par défaut et affiché comme tel. 44 tests.
This commit is contained in:
1 parent
1ec6443f14
commit
a03accb388
12 files changed
+1280
-41
No files matched your search
+34
-7
@@ -10,13 +10,35 @@ L'interface est une **vue d'un modèle d'état sérialisable** : elle ne porte a
|
||||
|
||||
## État actuel
|
||||
|
||||
Squelette uniquement, sans framework UI ni bibliothèque de composants :
|
||||
Squelette sans framework UI ni bibliothèque de composants :
|
||||
|
||||
- projet Vite + TypeScript strict, tests Vitest ;
|
||||
- état minimal (`connection`, `protocolVersion`, liste d'intentions vide) et réduction pure ;
|
||||
- interface sobre affichant le titre, le statut de connexion et la mention **« Core simulé »** ;
|
||||
- connexion **simulée** : `SimulatedCoreConnection` n'ouvre aucune socket et reproduit la séquence `connecting` → `connected`. Tout ce qui est simulé est marqué comme tel dans l'interface ;
|
||||
- pas encore de protocole WebSocket réel ni de schéma partagé.
|
||||
- état minimal sérialisable (`connection`, `protocolVersion`, version du Core, raison de la dernière déconnexion, liste d'intentions vide) et réduction pure ;
|
||||
- interface sobre affichant le titre, le statut de connexion, le badge **« Core simulé »** ou **« Core réel »**, la version du Core et la raison de la dernière déconnexion ;
|
||||
- connexion **simulée** par défaut : `SimulatedCoreConnection` n'ouvre aucune socket et reproduit la séquence `connecting` → `connected` avec une version de Core fictive marquée comme simulée ;
|
||||
- connexion **réelle** optionnelle : `WebSocketCoreConnection` utilise l'API WebSocket native du navigateur, sans bibliothèque, et effectue la poignée de main `hello` → `welcome` ;
|
||||
- messages JSON **provisoires** dans `src/protocol/messages.ts`, en attendant le schéma partagé (Protobuf ou FlatBuffers, fixé par une ADR) ;
|
||||
- pas encore de serveur WebSocket côté Core, ni de reconnexion automatique.
|
||||
|
||||
## Se connecter à un Core
|
||||
|
||||
Par défaut, Studio reste en mode simulé. Pour viser un Core réel, ajouter le paramètre d'URL `core` avec une URL `ws://` ou `wss://` :
|
||||
|
||||
```text
|
||||
http://localhost:5173/?core=ws://127.0.0.1:8787
|
||||
```
|
||||
|
||||
Tout autre schéma (ou une URL invalide) est refusé : Studio reste en simulé et affiche la raison. Le Core n'a pas encore de serveur ; cette page décrit donc uniquement le client.
|
||||
|
||||
Le format des messages est **provisoire** et confiné à `src/protocol/` :
|
||||
|
||||
- enveloppe `{ "v": 1, "type": "...", "payload": { ... } }` ;
|
||||
- client → Core : `hello` `{ protocolVersion, client: "sequa-studio" }` ;
|
||||
- Core → client : `welcome` `{ protocolVersion, coreVersion }` et `error` `{ code, message }` ;
|
||||
- la poignée de main réussit si la version majeure de `welcome.protocolVersion` est celle de Studio et, tant que cette majeure vaut `0` (API instable selon semver), si la mineure est identique aussi ; un message `error`, un message indécodable, une version incompatible ou un délai de poignée de main dépassé (5 s par défaut) ferme le socket et affiche la raison.
|
||||
|
||||
Ce format sera remplacé par un schéma binaire généré depuis `schema/` ; aucun autre module ne doit en dépendre.
|
||||
|
||||
|
||||
## Commandes
|
||||
|
||||
@@ -41,11 +63,16 @@ studio/
|
||||
│ ├── main.ts # montage dans #app, câblage connexion ↔ vue
|
||||
│ ├── style.css # thème sombre minimal
|
||||
│ ├── model/state.ts # StudioState, événements, état initial, reduce pure
|
||||
│ ├── protocol/connection.ts # interface CoreConnection + connexion SIMULÉE
|
||||
│ ├── protocol/
|
||||
│ │ ├── connection.ts # interface CoreConnection + connexion SIMULÉE
|
||||
│ │ ├── messages.ts # messages JSON provisoires (encode/decode validants)
|
||||
│ │ └── websocket.ts # connexion WebSocket réelle (API native)
|
||||
│ └── ui/app.ts # rendu de l'état et actions de connexion
|
||||
└── tests/
|
||||
├── state.test.ts
|
||||
└── connection.test.ts
|
||||
├── connection.test.ts
|
||||
├── messages.test.ts
|
||||
└── websocket.test.ts
|
||||
```
|
||||
|
||||
## Licences
|
||||
|
||||
+45
-2
@@ -2,22 +2,65 @@
|
||||
|
||||
import './style.css';
|
||||
import { createInitialState } from './model/state';
|
||||
import type { CoreConnection } from './protocol/connection';
|
||||
import { SimulatedCoreConnection } from './protocol/connection';
|
||||
import { WebSocketCoreConnection } from './protocol/websocket';
|
||||
import { mountApp } from './ui/app';
|
||||
|
||||
const CORE_PARAM = 'core';
|
||||
|
||||
interface ConnectionChoice {
|
||||
readonly connection: CoreConnection;
|
||||
readonly notice?: string;
|
||||
}
|
||||
|
||||
/** Accepte uniquement une URL absolue de schéma ws: ou wss:. */
|
||||
function parseCoreUrl(raw: string): string | null {
|
||||
try {
|
||||
const url = new URL(raw);
|
||||
|
||||
return url.protocol === 'ws:' || url.protocol === 'wss:' ? url.href : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Choisit la connexion : Core réel si `?core=ws://…` ou `?core=wss://…`,
|
||||
* Core simulé sinon (ou si l'URL demandée est invalide).
|
||||
*/
|
||||
function chooseConnection(requested: string | null): ConnectionChoice {
|
||||
if (requested === null) {
|
||||
return { connection: new SimulatedCoreConnection() };
|
||||
}
|
||||
|
||||
const url = parseCoreUrl(requested);
|
||||
|
||||
if (url !== null) {
|
||||
return { connection: new WebSocketCoreConnection(url) };
|
||||
}
|
||||
|
||||
return {
|
||||
connection: new SimulatedCoreConnection(),
|
||||
notice: `Paramètre ?core= ignoré : « ${requested} » n’est pas une URL ws:// ou wss://. Le Core reste simulé.`,
|
||||
};
|
||||
}
|
||||
|
||||
const root = document.querySelector<HTMLElement>('#app');
|
||||
|
||||
if (root === null) {
|
||||
throw new Error('Élément #app introuvable dans index.html');
|
||||
}
|
||||
|
||||
// SIMULÉ : aucune connexion réseau n'est établie à ce stade.
|
||||
const connection = new SimulatedCoreConnection();
|
||||
const choice = chooseConnection(new URLSearchParams(window.location.search).get(CORE_PARAM));
|
||||
const { connection } = choice;
|
||||
|
||||
const app = mountApp({
|
||||
root,
|
||||
initialState: createInitialState(),
|
||||
coreLabel: connection.label,
|
||||
coreSimulated: connection.simulated,
|
||||
...(choice.notice === undefined ? {} : { connectionNotice: choice.notice }),
|
||||
actions: {
|
||||
connect: () => {
|
||||
connection.connect();
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
/** Version du protocole Studio — Core. Aucun protocole réel n'est encore implémenté. */
|
||||
/**
|
||||
* Version du protocole Studio — Core.
|
||||
*
|
||||
* Le format des messages reste provisoire (voir `src/protocol/messages.ts`) ;
|
||||
* seule la règle de compatibilité par majeure est stable.
|
||||
*/
|
||||
export const PROTOCOL_VERSION = '0.1.0';
|
||||
|
||||
export type ConnectionStatus = 'disconnected' | 'connecting' | 'connected';
|
||||
@@ -15,19 +20,24 @@ export interface Intention {
|
||||
* Modèle d'état de Studio.
|
||||
*
|
||||
* Il est sérialisable (JSON) et ne contient aucune logique produit : l'interface
|
||||
* n'en est qu'une vue.
|
||||
* n'en est qu'une vue. Les détails de connexion sont absents quand ils n'ont
|
||||
* pas de sens (`coreVersion` hors connexion, aucune déconnexion survenue).
|
||||
*/
|
||||
export interface StudioState {
|
||||
readonly connection: ConnectionStatus;
|
||||
readonly protocolVersion: string;
|
||||
/** Version annoncée par le Core pendant la poignée de main, si connecté. */
|
||||
readonly coreVersion?: string;
|
||||
/** Raison de la dernière déconnexion ; absente si aucune n'est survenue. */
|
||||
readonly lastDisconnectReason?: string;
|
||||
readonly intentions: readonly Intention[];
|
||||
}
|
||||
|
||||
/** Événements de connexion au Core, seuls événements du squelette. */
|
||||
export type StudioEvent =
|
||||
| { readonly type: 'core/connecting' }
|
||||
| { readonly type: 'core/connected' }
|
||||
| { readonly type: 'core/disconnected' };
|
||||
| { readonly type: 'core/connected'; readonly coreVersion: string }
|
||||
| { readonly type: 'core/disconnected'; readonly reason?: string };
|
||||
|
||||
/** État initial : rien n'est connecté, aucune intention n'existe encore. */
|
||||
export function createInitialState(): StudioState {
|
||||
@@ -38,6 +48,15 @@ export function createInitialState(): StudioState {
|
||||
};
|
||||
}
|
||||
|
||||
/** Copie l'état sans les détails d'une connexion précédente. */
|
||||
function withoutConnectionDetails(state: StudioState): StudioState {
|
||||
return {
|
||||
connection: state.connection,
|
||||
protocolVersion: state.protocolVersion,
|
||||
intentions: state.intentions,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Réduction pure : produit un nouvel état sans jamais modifier l'état reçu.
|
||||
* L'interface ne change d'état que par cette fonction.
|
||||
@@ -45,11 +64,23 @@ export function createInitialState(): StudioState {
|
||||
export function reduce(state: StudioState, event: StudioEvent): StudioState {
|
||||
switch (event.type) {
|
||||
case 'core/connecting':
|
||||
return { ...state, connection: 'connecting' };
|
||||
return { ...withoutConnectionDetails(state), connection: 'connecting' };
|
||||
case 'core/connected':
|
||||
return { ...state, connection: 'connected' };
|
||||
case 'core/disconnected':
|
||||
return { ...state, connection: 'disconnected' };
|
||||
return {
|
||||
...withoutConnectionDetails(state),
|
||||
connection: 'connected',
|
||||
coreVersion: event.coreVersion,
|
||||
};
|
||||
case 'core/disconnected': {
|
||||
const disconnected: StudioState = {
|
||||
...withoutConnectionDetails(state),
|
||||
connection: 'disconnected',
|
||||
};
|
||||
|
||||
return event.reason === undefined
|
||||
? disconnected
|
||||
: { ...disconnected, lastDisconnectReason: event.reason };
|
||||
}
|
||||
}
|
||||
|
||||
const unreachable: never = event;
|
||||
|
||||
@@ -21,10 +21,13 @@ export interface CoreConnection {
|
||||
onEvent(listener: CoreEventListener): () => void;
|
||||
}
|
||||
|
||||
/** Version fictive annoncée par le Core simulé ; le suffixe la marque comme telle. */
|
||||
export const SIMULATED_CORE_VERSION = '0.0.0-simulé';
|
||||
|
||||
const STATUS_EVENTS: Record<ConnectionStatus, StudioEvent> = {
|
||||
disconnected: { type: 'core/disconnected' },
|
||||
connecting: { type: 'core/connecting' },
|
||||
connected: { type: 'core/connected' },
|
||||
connected: { type: 'core/connected', coreVersion: SIMULATED_CORE_VERSION },
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
/**
|
||||
* Messages JSON PROVISOIRES du protocole Studio — Core.
|
||||
*
|
||||
* Le format définitif (Protobuf ou FlatBuffers) sera fixé par une ADR et généré
|
||||
* depuis `schema/`. Ces types et fonctions ne sont donc qu'un brouillon local :
|
||||
* aucun autre module en dehors de `src/protocol/` ne doit dépendre de leur
|
||||
* forme. Ils restent remplaçables derrière `CoreConnection`.
|
||||
*
|
||||
* Enveloppe : `{ v: number, type: string, payload: unknown }`.
|
||||
*/
|
||||
|
||||
import { PROTOCOL_VERSION } from '../model/state';
|
||||
|
||||
/** Version de l'enveloppe JSON provisoire, indépendante de la version du protocole. */
|
||||
export const ENVELOPE_VERSION = 1;
|
||||
|
||||
export interface HelloMessage {
|
||||
readonly type: 'hello';
|
||||
readonly payload: {
|
||||
readonly protocolVersion: string;
|
||||
readonly client: 'sequa-studio';
|
||||
};
|
||||
}
|
||||
|
||||
export interface WelcomeMessage {
|
||||
readonly type: 'welcome';
|
||||
readonly payload: {
|
||||
readonly protocolVersion: string;
|
||||
readonly coreVersion: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ErrorMessage {
|
||||
readonly type: 'error';
|
||||
readonly payload: {
|
||||
readonly code: string;
|
||||
readonly message: string;
|
||||
};
|
||||
}
|
||||
|
||||
/** Tout message compris par Studio, dans les deux sens. */
|
||||
export type StudioMessage = HelloMessage | WelcomeMessage | ErrorMessage;
|
||||
|
||||
export type DecodeErrorCode =
|
||||
| 'invalid-json'
|
||||
| 'invalid-envelope'
|
||||
| 'unsupported-version'
|
||||
| 'unknown-type'
|
||||
| 'invalid-payload';
|
||||
|
||||
export interface DecodeError {
|
||||
readonly code: DecodeErrorCode;
|
||||
readonly message: string;
|
||||
}
|
||||
|
||||
export type DecodeResult =
|
||||
| { readonly ok: true; readonly message: StudioMessage }
|
||||
| { readonly ok: false; readonly error: DecodeError };
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function fail(code: DecodeErrorCode, message: string): DecodeResult {
|
||||
return { ok: false, error: { code, message } };
|
||||
}
|
||||
|
||||
function isNonEmptyString(value: unknown): value is string {
|
||||
return typeof value === 'string' && value.trim() !== '';
|
||||
}
|
||||
|
||||
/** Sérialise un message dans l'enveloppe JSON provisoire. */
|
||||
export function encode(message: StudioMessage): string {
|
||||
return JSON.stringify({
|
||||
v: ENVELOPE_VERSION,
|
||||
type: message.type,
|
||||
payload: message.payload,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Décode et valide une enveloppe JSON.
|
||||
*
|
||||
* Ne lève jamais d'exception : tout JSON invalide, enveloppe incorrecte, type
|
||||
* inconnu ou payload mal formé produit une erreur typée.
|
||||
*/
|
||||
export function decode(text: string): DecodeResult {
|
||||
let parsed: unknown;
|
||||
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} catch {
|
||||
return fail('invalid-json', 'Le message n’est pas du JSON valide.');
|
||||
}
|
||||
|
||||
if (!isRecord(parsed)) {
|
||||
return fail('invalid-envelope', 'L’enveloppe doit être un objet JSON.');
|
||||
}
|
||||
|
||||
const { v, type, payload } = parsed;
|
||||
|
||||
if (typeof v !== 'number' || !Number.isInteger(v)) {
|
||||
return fail('invalid-envelope', 'Le champ « v » doit être un entier.');
|
||||
}
|
||||
|
||||
if (v !== ENVELOPE_VERSION) {
|
||||
return fail(
|
||||
'unsupported-version',
|
||||
`Version d’enveloppe ${String(v)} non prise en charge (attendu ${String(ENVELOPE_VERSION)}).`,
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof type !== 'string' || type === '') {
|
||||
return fail('invalid-envelope', 'Le champ « type » doit être une chaîne non vide.');
|
||||
}
|
||||
|
||||
if (type !== 'hello' && type !== 'welcome' && type !== 'error') {
|
||||
return fail('unknown-type', `Type de message inconnu : « ${type} ».`);
|
||||
}
|
||||
|
||||
if (!isRecord(payload)) {
|
||||
return fail('invalid-payload', 'Le champ « payload » doit être un objet JSON.');
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case 'hello': {
|
||||
const { protocolVersion, client } = payload;
|
||||
|
||||
if (!isNonEmptyString(protocolVersion)) {
|
||||
return fail('invalid-payload', 'hello : « protocolVersion » doit être une chaîne non vide.');
|
||||
}
|
||||
|
||||
if (client !== 'sequa-studio') {
|
||||
return fail('invalid-payload', 'hello : « client » doit valoir « sequa-studio ».');
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
message: { type: 'hello', payload: { protocolVersion, client: 'sequa-studio' } },
|
||||
};
|
||||
}
|
||||
|
||||
case 'welcome': {
|
||||
const { protocolVersion, coreVersion } = payload;
|
||||
|
||||
if (!isNonEmptyString(protocolVersion)) {
|
||||
return fail('invalid-payload', 'welcome : « protocolVersion » doit être une chaîne non vide.');
|
||||
}
|
||||
|
||||
if (!isNonEmptyString(coreVersion)) {
|
||||
return fail('invalid-payload', 'welcome : « coreVersion » doit être une chaîne non vide.');
|
||||
}
|
||||
|
||||
return { ok: true, message: { type: 'welcome', payload: { protocolVersion, coreVersion } } };
|
||||
}
|
||||
|
||||
case 'error': {
|
||||
const { code, message } = payload;
|
||||
|
||||
if (!isNonEmptyString(code)) {
|
||||
return fail('invalid-payload', 'error : « code » doit être une chaîne non vide.');
|
||||
}
|
||||
|
||||
if (typeof message !== 'string') {
|
||||
return fail('invalid-payload', 'error : « message » doit être une chaîne.');
|
||||
}
|
||||
|
||||
return { ok: true, message: { type: 'error', payload: { code, message } } };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface ProtocolVersion {
|
||||
readonly major: number;
|
||||
readonly minor: number;
|
||||
}
|
||||
|
||||
function parseProtocolVersion(version: string): ProtocolVersion | null {
|
||||
const match = /^(\d+)\.(\d+)\.\d+(?:[-+].*)?$/.exec(version);
|
||||
|
||||
if (match === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { major: Number(match[1]), minor: Number(match[2]) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Vrai lorsque la version du Core est compatible avec celle de Studio :
|
||||
* même majeure, et si la majeure vaut 0 (API instable selon semver), même
|
||||
* mineure également. Une version illisible est considérée comme incompatible.
|
||||
*/
|
||||
export function isCompatibleProtocolVersion(version: string): boolean {
|
||||
const remote = parseProtocolVersion(version);
|
||||
const studio = parseProtocolVersion(PROTOCOL_VERSION);
|
||||
|
||||
if (remote === null || studio === null || remote.major !== studio.major) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return studio.major !== 0 || remote.minor === studio.minor;
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
import type { ConnectionStatus, StudioEvent } from '../model/state';
|
||||
import { PROTOCOL_VERSION } from '../model/state';
|
||||
import type { CoreConnection, CoreEventListener } from './connection';
|
||||
import { decode, encode, isCompatibleProtocolVersion } from './messages';
|
||||
|
||||
/**
|
||||
* Sous-ensemble de `WebSocket` utilisé par Studio.
|
||||
*
|
||||
* Cette interface permet d'injecter un faux socket dans les tests sans
|
||||
* bibliothèque ni vrai réseau, tout en restant structurellement compatible avec
|
||||
* l'API WebSocket native du navigateur.
|
||||
*/
|
||||
export interface WebSocketLike {
|
||||
readonly readyState: number;
|
||||
send(data: string): void;
|
||||
close(code?: number, reason?: string): void;
|
||||
onopen: ((event: unknown) => void) | null;
|
||||
onmessage: ((event: WebSocketMessageEvent) => void) | null;
|
||||
onclose: ((event: WebSocketCloseEvent) => void) | null;
|
||||
onerror: ((event: unknown) => void) | null;
|
||||
}
|
||||
|
||||
export interface WebSocketMessageEvent {
|
||||
readonly data: unknown;
|
||||
}
|
||||
|
||||
export interface WebSocketCloseEvent {
|
||||
readonly code?: number;
|
||||
readonly reason?: string;
|
||||
}
|
||||
|
||||
export interface WebSocketCoreConnectionOptions {
|
||||
/** Fabrique de socket ; remplacée par un faux socket dans les tests. */
|
||||
readonly createSocket?: (url: string) => WebSocketLike;
|
||||
/** Délai maximal de la poignée de main, en millisecondes (5000 par défaut). */
|
||||
readonly handshakeTimeoutMs?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_HANDSHAKE_TIMEOUT_MS = 5000;
|
||||
|
||||
function createBrowserSocket(url: string): WebSocketLike {
|
||||
return new WebSocket(url) as unknown as WebSocketLike;
|
||||
}
|
||||
|
||||
function describeError(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
function describeClose(event: WebSocketCloseEvent): string {
|
||||
if (event.reason !== undefined && event.reason !== '') {
|
||||
return event.reason;
|
||||
}
|
||||
|
||||
if (event.code !== undefined) {
|
||||
return `Connexion fermée par le Core (code ${String(event.code)}).`;
|
||||
}
|
||||
|
||||
return 'Connexion fermée par le Core.';
|
||||
}
|
||||
|
||||
/**
|
||||
* Connexion WebSocket RÉELLE au Core, sans bibliothèque : elle s'appuie sur
|
||||
* l'API WebSocket native du navigateur.
|
||||
*
|
||||
* Séquence : `connecting` → envoi de `hello` à l'ouverture → réception de
|
||||
* `welcome` avec une version compatible → `connected`. Toute erreur de
|
||||
* poignée de main (ou son expiration) ferme le socket et émet `disconnected`
|
||||
* avec une raison. Aucune reconnexion automatique n'est tentée (étape
|
||||
* ultérieure).
|
||||
*/
|
||||
export class WebSocketCoreConnection implements CoreConnection {
|
||||
public readonly label: string;
|
||||
public readonly simulated = false;
|
||||
|
||||
private readonly url: string;
|
||||
private readonly createSocket: (url: string) => WebSocketLike;
|
||||
private readonly handshakeTimeoutMs: number;
|
||||
private readonly listeners = new Set<CoreEventListener>();
|
||||
private status: ConnectionStatus = 'disconnected';
|
||||
private socket: WebSocketLike | null = null;
|
||||
private handshakeTimer: ReturnType<typeof globalThis.setTimeout> | null = null;
|
||||
|
||||
public constructor(url: string, options: WebSocketCoreConnectionOptions = {}) {
|
||||
this.url = url;
|
||||
this.label = `Core ${url}`;
|
||||
this.createSocket = options.createSocket ?? createBrowserSocket;
|
||||
this.handshakeTimeoutMs = options.handshakeTimeoutMs ?? DEFAULT_HANDSHAKE_TIMEOUT_MS;
|
||||
}
|
||||
|
||||
public connect(): void {
|
||||
if (this.status !== 'disconnected') {
|
||||
return;
|
||||
}
|
||||
|
||||
this.status = 'connecting';
|
||||
this.emit({ type: 'core/connecting' });
|
||||
this.clearHandshakeTimer();
|
||||
|
||||
let socket: WebSocketLike;
|
||||
|
||||
try {
|
||||
socket = this.createSocket(this.url);
|
||||
} catch (error) {
|
||||
this.fail(`Impossible d’ouvrir la connexion : ${describeError(error)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.socket = socket;
|
||||
socket.onopen = () => {
|
||||
this.handleOpen(socket);
|
||||
};
|
||||
socket.onmessage = (event) => {
|
||||
this.handleMessage(socket, event);
|
||||
};
|
||||
socket.onclose = (event) => {
|
||||
this.handleClose(socket, event);
|
||||
};
|
||||
socket.onerror = () => {
|
||||
this.handleError(socket);
|
||||
};
|
||||
this.armHandshakeTimer();
|
||||
}
|
||||
|
||||
public disconnect(): void {
|
||||
if (this.status === 'disconnected') {
|
||||
return;
|
||||
}
|
||||
|
||||
const socket = this.socket;
|
||||
this.socket = null;
|
||||
this.status = 'disconnected';
|
||||
this.clearHandshakeTimer();
|
||||
this.closeQuietly(socket);
|
||||
this.emit({ type: 'core/disconnected' });
|
||||
}
|
||||
|
||||
public onEvent(listener: CoreEventListener): () => void {
|
||||
this.listeners.add(listener);
|
||||
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
private handleOpen(socket: WebSocketLike): void {
|
||||
if (this.socket !== socket || this.status !== 'connecting') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
socket.send(
|
||||
encode({
|
||||
type: 'hello',
|
||||
payload: { protocolVersion: PROTOCOL_VERSION, client: 'sequa-studio' },
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
this.fail(`Envoi de la poignée de main impossible : ${describeError(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
private handleMessage(socket: WebSocketLike, event: WebSocketMessageEvent): void {
|
||||
if (this.socket !== socket || this.status !== 'connecting') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof event.data !== 'string') {
|
||||
this.fail('Message non textuel reçu pendant la poignée de main.');
|
||||
return;
|
||||
}
|
||||
|
||||
const result = decode(event.data);
|
||||
|
||||
if (!result.ok) {
|
||||
this.fail(`Message illisible pendant la poignée de main : ${result.error.message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const message = result.message;
|
||||
|
||||
switch (message.type) {
|
||||
case 'welcome': {
|
||||
const remoteVersion = message.payload.protocolVersion;
|
||||
|
||||
if (!isCompatibleProtocolVersion(remoteVersion)) {
|
||||
this.fail(
|
||||
`Version de protocole incompatible : Core ${remoteVersion}, Studio ${PROTOCOL_VERSION}.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.status = 'connected';
|
||||
this.clearHandshakeTimer();
|
||||
this.emit({ type: 'core/connected', coreVersion: message.payload.coreVersion });
|
||||
return;
|
||||
}
|
||||
case 'error':
|
||||
this.fail(
|
||||
`Le Core a signalé une erreur ${message.payload.code} : ${message.payload.message}`,
|
||||
);
|
||||
return;
|
||||
case 'hello':
|
||||
this.fail('Le Core a envoyé un message réservé au client (« hello »).');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private handleClose(socket: WebSocketLike, event: WebSocketCloseEvent): void {
|
||||
if (this.socket !== socket) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.socket = null;
|
||||
|
||||
if (this.status === 'disconnected') {
|
||||
return;
|
||||
}
|
||||
|
||||
this.status = 'disconnected';
|
||||
this.clearHandshakeTimer();
|
||||
this.emit({ type: 'core/disconnected', reason: describeClose(event) });
|
||||
}
|
||||
|
||||
private handleError(socket: WebSocketLike): void {
|
||||
if (this.socket !== socket || this.status !== 'connecting') {
|
||||
return;
|
||||
}
|
||||
|
||||
this.fail('Erreur de connexion au Core.');
|
||||
}
|
||||
|
||||
/** Ferme le socket et signale l'échec de poignée de main. */
|
||||
private fail(reason: string): void {
|
||||
const socket = this.socket;
|
||||
this.socket = null;
|
||||
this.status = 'disconnected';
|
||||
this.clearHandshakeTimer();
|
||||
this.closeQuietly(socket);
|
||||
this.emit({ type: 'core/disconnected', reason });
|
||||
}
|
||||
|
||||
private armHandshakeTimer(): void {
|
||||
this.clearHandshakeTimer();
|
||||
this.handshakeTimer = globalThis.setTimeout(() => {
|
||||
this.handshakeTimer = null;
|
||||
|
||||
if (this.status !== 'connecting') {
|
||||
return;
|
||||
}
|
||||
|
||||
this.fail(`Délai de poignée de main dépassé (${String(this.handshakeTimeoutMs)} ms).`);
|
||||
}, this.handshakeTimeoutMs);
|
||||
}
|
||||
|
||||
private clearHandshakeTimer(): void {
|
||||
if (this.handshakeTimer === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
globalThis.clearTimeout(this.handshakeTimer);
|
||||
this.handshakeTimer = null;
|
||||
}
|
||||
|
||||
/** Détache les gestionnaires puis ferme, sans jamais laisser fuiter d'erreur. */
|
||||
private closeQuietly(socket: WebSocketLike | null): void {
|
||||
if (socket === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
socket.onopen = null;
|
||||
socket.onmessage = null;
|
||||
socket.onclose = null;
|
||||
socket.onerror = null;
|
||||
|
||||
try {
|
||||
socket.close();
|
||||
} catch {
|
||||
// Fermer un socket déjà fermé ne doit pas faire échouer la déconnexion.
|
||||
}
|
||||
}
|
||||
|
||||
private emit(event: StudioEvent): void {
|
||||
for (const listener of this.listeners) {
|
||||
listener(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -80,6 +80,12 @@ body {
|
||||
border: 1px solid rgb(232 180 92 / 35%);
|
||||
}
|
||||
|
||||
.badge--live {
|
||||
color: var(--ok);
|
||||
background: rgb(87 211 140 / 12%);
|
||||
border: 1px solid rgb(87 211 140 / 35%);
|
||||
}
|
||||
|
||||
.status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
+45
-16
@@ -13,6 +13,10 @@ export interface AppOptions {
|
||||
readonly initialState: StudioState;
|
||||
/** Libellé de la connexion ; « Core simulé » pour l'implémentation factice. */
|
||||
readonly coreLabel: string;
|
||||
/** Vrai lorsque la connexion ne contacte aucun Core réel. */
|
||||
readonly coreSimulated: boolean;
|
||||
/** Explication facultative du choix de connexion (ex. paramètre ignoré). */
|
||||
readonly connectionNotice?: string;
|
||||
readonly actions: AppActions;
|
||||
}
|
||||
|
||||
@@ -28,6 +32,8 @@ const STATUS_LABELS: Record<ConnectionStatus, string> = {
|
||||
connected: 'Connecté',
|
||||
};
|
||||
|
||||
const NO_VALUE = '—';
|
||||
|
||||
function element<K extends keyof HTMLElementTagNameMap>(
|
||||
tag: K,
|
||||
className?: string,
|
||||
@@ -46,6 +52,10 @@ function element<K extends keyof HTMLElementTagNameMap>(
|
||||
return node;
|
||||
}
|
||||
|
||||
function fact(term: string, value: string): [HTMLElement, HTMLElement] {
|
||||
return [element('dt', undefined, term), element('dd', 'facts__value', value)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Monte la vue de Studio dans `root`.
|
||||
*
|
||||
@@ -53,7 +63,7 @@ function element<K extends keyof HTMLElementTagNameMap>(
|
||||
* passent par les actions (connexion) puis par `dispatch`, donc par `reduce`.
|
||||
*/
|
||||
export function mountApp(options: AppOptions): App {
|
||||
const { root, initialState, coreLabel, actions } = options;
|
||||
const { root, initialState, coreLabel, coreSimulated, connectionNotice, actions } = options;
|
||||
let state = initialState;
|
||||
|
||||
root.replaceChildren();
|
||||
@@ -62,9 +72,15 @@ export function mountApp(options: AppOptions): App {
|
||||
|
||||
const header = element('header', 'studio__header');
|
||||
const title = element('h1', 'studio__title', 'SEQUA Studio');
|
||||
const simulationBadge = element('span', 'badge badge--simulated', coreLabel);
|
||||
simulationBadge.title = 'SIMULÉ — aucun Core réel n’est contacté.';
|
||||
header.append(title, simulationBadge);
|
||||
const modeBadge = element(
|
||||
'span',
|
||||
coreSimulated ? 'badge badge--simulated' : 'badge badge--live',
|
||||
coreSimulated ? 'Core simulé' : 'Core réel',
|
||||
);
|
||||
modeBadge.title = coreSimulated
|
||||
? 'SIMULÉ — aucun Core réel n’est contacté.'
|
||||
: 'RÉEL — connexion WebSocket au Core.';
|
||||
header.append(title, modeBadge);
|
||||
|
||||
const statusLine = element('p', 'status');
|
||||
statusLine.setAttribute('role', 'status');
|
||||
@@ -74,18 +90,29 @@ export function mountApp(options: AppOptions): App {
|
||||
const statusText = element('span', 'status__text');
|
||||
statusLine.append(statusDot, statusText);
|
||||
|
||||
const simulationNote = element(
|
||||
'p',
|
||||
'note',
|
||||
'Connexion simulée : l’interface est développée sans Core réel, en attendant le protocole WebSocket.',
|
||||
);
|
||||
const defaultNote = coreSimulated
|
||||
? 'Connexion simulée : aucun Core réel n’est contacté. Le protocole WebSocket est implémenté côté Studio, mais le Core n’a pas encore de serveur.'
|
||||
: `Connexion WebSocket réelle vers ${coreLabel}.`;
|
||||
const note = element('p', 'note', connectionNotice ?? defaultNote);
|
||||
|
||||
const details = element('dl', 'facts');
|
||||
const versionTerm = element('dt', undefined, 'Version du protocole');
|
||||
const versionValue = element('dd', 'facts__value');
|
||||
const intentionsTerm = element('dt', undefined, 'Intentions');
|
||||
const intentionsValue = element('dd', 'facts__value');
|
||||
details.append(versionTerm, versionValue, intentionsTerm, intentionsValue);
|
||||
const [coreTerm, coreValue] = fact('Core', coreLabel);
|
||||
const [protocolTerm, protocolValue] = fact('Version du protocole', state.protocolVersion);
|
||||
const [coreVersionTerm, coreVersionValue] = fact('Version du Core', NO_VALUE);
|
||||
const [disconnectTerm, disconnectValue] = fact('Dernière déconnexion', NO_VALUE);
|
||||
const [intentionsTerm, intentionsValue] = fact('Intentions', String(state.intentions.length));
|
||||
details.append(
|
||||
coreTerm,
|
||||
coreValue,
|
||||
protocolTerm,
|
||||
protocolValue,
|
||||
coreVersionTerm,
|
||||
coreVersionValue,
|
||||
disconnectTerm,
|
||||
disconnectValue,
|
||||
intentionsTerm,
|
||||
intentionsValue,
|
||||
);
|
||||
|
||||
const controls = element('div', 'controls');
|
||||
const connectButton = element('button', 'button', 'Connecter');
|
||||
@@ -100,13 +127,15 @@ export function mountApp(options: AppOptions): App {
|
||||
});
|
||||
controls.append(connectButton, disconnectButton);
|
||||
|
||||
shell.append(header, statusLine, simulationNote, details, controls);
|
||||
shell.append(header, statusLine, note, details, controls);
|
||||
root.append(shell);
|
||||
|
||||
const render = (): void => {
|
||||
statusLine.dataset['status'] = state.connection;
|
||||
statusText.textContent = STATUS_LABELS[state.connection];
|
||||
versionValue.textContent = state.protocolVersion;
|
||||
protocolValue.textContent = state.protocolVersion;
|
||||
coreVersionValue.textContent = state.coreVersion ?? NO_VALUE;
|
||||
disconnectValue.textContent = state.lastDisconnectReason ?? NO_VALUE;
|
||||
intentionsValue.textContent = String(state.intentions.length);
|
||||
connectButton.disabled = state.connection !== 'disconnected';
|
||||
disconnectButton.disabled = state.connection === 'disconnected';
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { SimulatedCoreConnection } from '../src/protocol/connection';
|
||||
import {
|
||||
SIMULATED_CORE_VERSION,
|
||||
SimulatedCoreConnection,
|
||||
} from '../src/protocol/connection';
|
||||
|
||||
describe('SimulatedCoreConnection', () => {
|
||||
it('se déclare clairement comme simulée', () => {
|
||||
@@ -20,7 +23,14 @@ describe('SimulatedCoreConnection', () => {
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(2);
|
||||
expect(listener).toHaveBeenNthCalledWith(1, { type: 'core/connecting' });
|
||||
expect(listener).toHaveBeenNthCalledWith(2, { type: 'core/connected' });
|
||||
expect(listener).toHaveBeenNthCalledWith(2, {
|
||||
type: 'core/connected',
|
||||
coreVersion: SIMULATED_CORE_VERSION,
|
||||
});
|
||||
});
|
||||
|
||||
it('marque explicitement la version du Core comme simulée', () => {
|
||||
expect(SIMULATED_CORE_VERSION).toContain('simulé');
|
||||
});
|
||||
|
||||
it('émet disconnected à la déconnexion', () => {
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { DecodeErrorCode, StudioMessage } from '../src/protocol/messages';
|
||||
import {
|
||||
ENVELOPE_VERSION,
|
||||
decode,
|
||||
encode,
|
||||
isCompatibleProtocolVersion,
|
||||
} from '../src/protocol/messages';
|
||||
|
||||
const HELLO: StudioMessage = {
|
||||
type: 'hello',
|
||||
payload: { protocolVersion: '0.1.0', client: 'sequa-studio' },
|
||||
};
|
||||
|
||||
const WELCOME: StudioMessage = {
|
||||
type: 'welcome',
|
||||
payload: { protocolVersion: '0.1.0', coreVersion: '1.2.3' },
|
||||
};
|
||||
|
||||
const ERROR: StudioMessage = {
|
||||
type: 'error',
|
||||
payload: { code: 'internal', message: 'Échec interne.' },
|
||||
};
|
||||
|
||||
function expectError(text: string, code: DecodeErrorCode): void {
|
||||
const result = decode(text);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
|
||||
if (result.ok) {
|
||||
throw new Error('Le décodage aurait dû échouer.');
|
||||
}
|
||||
|
||||
expect(result.error.code).toBe(code);
|
||||
}
|
||||
|
||||
describe('encode', () => {
|
||||
it('produit l’enveloppe provisoire versionnée', () => {
|
||||
expect(JSON.parse(encode(HELLO))).toEqual({
|
||||
v: ENVELOPE_VERSION,
|
||||
type: 'hello',
|
||||
payload: { protocolVersion: '0.1.0', client: 'sequa-studio' },
|
||||
});
|
||||
});
|
||||
|
||||
it('fait un aller-retour pour chaque message', () => {
|
||||
for (const message of [HELLO, WELCOME, ERROR]) {
|
||||
expect(decode(encode(message))).toEqual({ ok: true, message });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('decode', () => {
|
||||
it('rejette un JSON invalide sans lever d’exception', () => {
|
||||
expectError('{', 'invalid-json');
|
||||
expectError('', 'invalid-json');
|
||||
expectError('undefined', 'invalid-json');
|
||||
});
|
||||
|
||||
it('rejette une enveloppe qui n’est pas un objet', () => {
|
||||
expectError('null', 'invalid-envelope');
|
||||
expectError('[]', 'invalid-envelope');
|
||||
expectError('"hello"', 'invalid-envelope');
|
||||
expectError('42', 'invalid-envelope');
|
||||
});
|
||||
|
||||
it('rejette un champ v manquant ou mal typé', () => {
|
||||
expectError(JSON.stringify({ type: 'welcome', payload: {} }), 'invalid-envelope');
|
||||
expectError(JSON.stringify({ v: '1', type: 'welcome', payload: {} }), 'invalid-envelope');
|
||||
expectError(JSON.stringify({ v: 1.5, type: 'welcome', payload: {} }), 'invalid-envelope');
|
||||
});
|
||||
|
||||
it('rejette une version d’enveloppe inconnue', () => {
|
||||
expectError(
|
||||
JSON.stringify({ v: ENVELOPE_VERSION + 1, type: 'welcome', payload: {} }),
|
||||
'unsupported-version',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejette un type manquant, vide ou non textuel', () => {
|
||||
expectError(JSON.stringify({ v: 1, payload: {} }), 'invalid-envelope');
|
||||
expectError(JSON.stringify({ v: 1, type: '', payload: {} }), 'invalid-envelope');
|
||||
expectError(JSON.stringify({ v: 1, type: 3, payload: {} }), 'invalid-envelope');
|
||||
});
|
||||
|
||||
it('rejette un type inconnu', () => {
|
||||
expectError(JSON.stringify({ v: 1, type: 'project/open', payload: {} }), 'unknown-type');
|
||||
});
|
||||
|
||||
it('rejette un payload absent ou qui n’est pas un objet', () => {
|
||||
expectError(JSON.stringify({ v: 1, type: 'welcome' }), 'invalid-payload');
|
||||
expectError(JSON.stringify({ v: 1, type: 'welcome', payload: null }), 'invalid-payload');
|
||||
expectError(JSON.stringify({ v: 1, type: 'welcome', payload: [] }), 'invalid-payload');
|
||||
expectError(JSON.stringify({ v: 1, type: 'welcome', payload: 'x' }), 'invalid-payload');
|
||||
});
|
||||
|
||||
it('rejette un hello mal formé', () => {
|
||||
expectError(
|
||||
JSON.stringify({ v: 1, type: 'hello', payload: { client: 'sequa-studio' } }),
|
||||
'invalid-payload',
|
||||
);
|
||||
expectError(
|
||||
JSON.stringify({ v: 1, type: 'hello', payload: { protocolVersion: ' ', client: 'sequa-studio' } }),
|
||||
'invalid-payload',
|
||||
);
|
||||
expectError(
|
||||
JSON.stringify({ v: 1, type: 'hello', payload: { protocolVersion: '0.1.0', client: 'autre' } }),
|
||||
'invalid-payload',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejette un welcome mal formé', () => {
|
||||
expectError(
|
||||
JSON.stringify({ v: 1, type: 'welcome', payload: { protocolVersion: '0.1.0' } }),
|
||||
'invalid-payload',
|
||||
);
|
||||
expectError(
|
||||
JSON.stringify({
|
||||
v: 1,
|
||||
type: 'welcome',
|
||||
payload: { protocolVersion: '0.1.0', coreVersion: '' },
|
||||
}),
|
||||
'invalid-payload',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejette un error mal formé', () => {
|
||||
expectError(
|
||||
JSON.stringify({ v: 1, type: 'error', payload: { message: 'Échec.' } }),
|
||||
'invalid-payload',
|
||||
);
|
||||
expectError(
|
||||
JSON.stringify({ v: 1, type: 'error', payload: { code: 'internal', message: 3 } }),
|
||||
'invalid-payload',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isCompatibleProtocolVersion', () => {
|
||||
it('accepte la même majeure et la même mineure en 0.x', () => {
|
||||
expect(isCompatibleProtocolVersion('0.1.0')).toBe(true);
|
||||
expect(isCompatibleProtocolVersion('0.1.5')).toBe(true);
|
||||
expect(isCompatibleProtocolVersion('0.1.0-alpha.1')).toBe(true);
|
||||
});
|
||||
|
||||
it('refuse une autre mineure tant que la majeure vaut 0', () => {
|
||||
expect(isCompatibleProtocolVersion('0.2.0')).toBe(false);
|
||||
expect(isCompatibleProtocolVersion('0.9.2')).toBe(false);
|
||||
expect(isCompatibleProtocolVersion('0.2.0-alpha.1')).toBe(false);
|
||||
});
|
||||
|
||||
it('refuse une autre majeure', () => {
|
||||
expect(isCompatibleProtocolVersion('1.0.0')).toBe(false);
|
||||
});
|
||||
|
||||
it('refuse une version illisible', () => {
|
||||
expect(isCompatibleProtocolVersion('0.1')).toBe(false);
|
||||
expect(isCompatibleProtocolVersion('inconnue')).toBe(false);
|
||||
expect(isCompatibleProtocolVersion('')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -23,25 +23,60 @@ describe('reduce', () => {
|
||||
it('passe par connecting puis connected', () => {
|
||||
const initial = createInitialState();
|
||||
const connecting = reduce(initial, { type: 'core/connecting' });
|
||||
const connected = reduce(connecting, { type: 'core/connected' });
|
||||
const connected = reduce(connecting, { type: 'core/connected', coreVersion: '1.2.3' });
|
||||
|
||||
expect(connecting.connection).toBe('connecting');
|
||||
expect(connected.connection).toBe('connected');
|
||||
expect(connected.coreVersion).toBe('1.2.3');
|
||||
});
|
||||
|
||||
it('revient à disconnected', () => {
|
||||
const connected = reduce(createInitialState(), { type: 'core/connected' });
|
||||
const disconnected = reduce(connected, { type: 'core/disconnected' });
|
||||
it('revient à disconnected avec la raison fournie', () => {
|
||||
const connected = reduce(createInitialState(), {
|
||||
type: 'core/connected',
|
||||
coreVersion: '1.2.3',
|
||||
});
|
||||
const disconnected = reduce(connected, {
|
||||
type: 'core/disconnected',
|
||||
reason: 'Connexion fermée par le Core.',
|
||||
});
|
||||
|
||||
expect(disconnected.connection).toBe('disconnected');
|
||||
expect(disconnected.lastDisconnectReason).toBe('Connexion fermée par le Core.');
|
||||
expect(disconnected.coreVersion).toBeUndefined();
|
||||
});
|
||||
|
||||
it('efface la raison et la version du Core à la reconnexion', () => {
|
||||
const failed = reduce(createInitialState(), {
|
||||
type: 'core/disconnected',
|
||||
reason: 'Erreur de connexion au Core.',
|
||||
});
|
||||
const reconnecting = reduce(failed, { type: 'core/connecting' });
|
||||
|
||||
expect(reconnecting.lastDisconnectReason).toBeUndefined();
|
||||
|
||||
const connected = reduce(reconnecting, { type: 'core/connected', coreVersion: '2.0.0' });
|
||||
const disconnected = reduce(connected, { type: 'core/disconnected' });
|
||||
|
||||
expect(disconnected.lastDisconnectReason).toBeUndefined();
|
||||
expect(disconnected.coreVersion).toBeUndefined();
|
||||
});
|
||||
|
||||
it('reste sérialisable avec les détails de connexion', () => {
|
||||
const state = reduce(createInitialState(), {
|
||||
type: 'core/connected',
|
||||
coreVersion: '1.2.3',
|
||||
});
|
||||
|
||||
expect(JSON.parse(JSON.stringify(state))).toEqual(state);
|
||||
});
|
||||
|
||||
it('est pure : l’état reçu reste inchangé', () => {
|
||||
const initial = createInitialState();
|
||||
const next = reduce(initial, { type: 'core/connected' });
|
||||
const next = reduce(initial, { type: 'core/connected', coreVersion: '1.2.3' });
|
||||
|
||||
expect(next).not.toBe(initial);
|
||||
expect(initial.connection).toBe('disconnected');
|
||||
expect(initial.coreVersion).toBeUndefined();
|
||||
expect(next.protocolVersion).toBe(initial.protocolVersion);
|
||||
expect(next.intentions).toBe(initial.intentions);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,399 @@
|
||||
// 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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user