diff --git a/studio/README.md b/studio/README.md index 0df80d9..187aba7 100644 --- a/studio/README.md +++ b/studio/README.md @@ -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 diff --git a/studio/src/main.ts b/studio/src/main.ts index e9cc48c..515b398 100644 --- a/studio/src/main.ts +++ b/studio/src/main.ts @@ -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('#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(); diff --git a/studio/src/model/state.ts b/studio/src/model/state.ts index e0a0efa..83ed702 100644 --- a/studio/src/model/state.ts +++ b/studio/src/model/state.ts @@ -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; diff --git a/studio/src/protocol/connection.ts b/studio/src/protocol/connection.ts index f5a61e7..a19e1b8 100644 --- a/studio/src/protocol/connection.ts +++ b/studio/src/protocol/connection.ts @@ -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 = { disconnected: { type: 'core/disconnected' }, connecting: { type: 'core/connecting' }, - connected: { type: 'core/connected' }, + connected: { type: 'core/connected', coreVersion: SIMULATED_CORE_VERSION }, }; /** diff --git a/studio/src/protocol/messages.ts b/studio/src/protocol/messages.ts new file mode 100644 index 0000000..edbdb54 --- /dev/null +++ b/studio/src/protocol/messages.ts @@ -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 { + 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; +} diff --git a/studio/src/protocol/websocket.ts b/studio/src/protocol/websocket.ts new file mode 100644 index 0000000..a4b4131 --- /dev/null +++ b/studio/src/protocol/websocket.ts @@ -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(); + private status: ConnectionStatus = 'disconnected'; + private socket: WebSocketLike | null = null; + private handshakeTimer: ReturnType | 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); + } + } +} diff --git a/studio/src/style.css b/studio/src/style.css index 58dd880..4648398 100644 --- a/studio/src/style.css +++ b/studio/src/style.css @@ -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; diff --git a/studio/src/ui/app.ts b/studio/src/ui/app.ts index 69b52ba..eb96d46 100644 --- a/studio/src/ui/app.ts +++ b/studio/src/ui/app.ts @@ -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 = { connected: 'Connecté', }; +const NO_VALUE = '—'; + function element( tag: K, className?: string, @@ -46,6 +52,10 @@ function element( 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( * 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'; diff --git a/studio/tests/connection.test.ts b/studio/tests/connection.test.ts index 7167562..b17e2a5 100644 --- a/studio/tests/connection.test.ts +++ b/studio/tests/connection.test.ts @@ -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', () => { diff --git a/studio/tests/messages.test.ts b/studio/tests/messages.test.ts new file mode 100644 index 0000000..62c1ffe --- /dev/null +++ b/studio/tests/messages.test.ts @@ -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); + }); +}); diff --git a/studio/tests/state.test.ts b/studio/tests/state.test.ts index d370178..a74360c 100644 --- a/studio/tests/state.test.ts +++ b/studio/tests/state.test.ts @@ -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); }); diff --git a/studio/tests/websocket.test.ts b/studio/tests/websocket.test.ts new file mode 100644 index 0000000..d3968a2 --- /dev/null +++ b/studio/tests/websocket.test.ts @@ -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 & { 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): 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(); + }); +});