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:
Ange-Marie MAURIN - A2M Systèmes committed 2026-11-06 19:38:58 +01:00
1 parent 1ec6443f14
commit a03accb388
12 files changed
+1280 -41

No files matched your search

+34 -7
View File
@@ -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
View File
@@ -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();
+39 -8
View File
@@ -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;
+4 -1
View File
@@ -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 },
};
/**
+204
View File
@@ -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;
}
+289
View File
@@ -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);
}
}
}
+6
View File
@@ -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
View File
@@ -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';
+12 -2
View File
@@ -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', () => {
+163
View File
@@ -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);
});
});
+40 -5
View File
@@ -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);
});
+399
View File
@@ -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();
});
});