diff --git a/README.md b/README.md index 9f3031a..c870b61 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,8 @@ Il n'y a pas d'intégration continue sur serveur : les vérifications se lancent | Windows | `.\scripts\build.ps1 [-Preset ] [-Clean] [-NoTests]` | Visual Studio 2022 avec outils C++ (MSVC, CMake et Ninja fournis) | | Windows, avant commit | `.\scripts\check.ps1` | idem + Node.js pour Studio | | Linux / macOS | `./scripts/build.sh []` | CMake ≥ 3.25, Ninja, GCC ou Clang | -| Lancer Studio | `.\scripts\studio.ps1 [-Core ws://hôte:port]` ou `./scripts/studio.sh [ws://hôte:port]` | Node.js 24 | +| Lancer SEQUA Studio (Core + interface) | `.\build\windows-msvc-debug\studio\host\app\sequa-studio.exe [--dev] [--watch]` (voir [studio/host](studio/host/README.md)) | build + `npm run build` dans `studio/ui` | +| Interface seule (développement) | `.\scripts\studio.ps1 [-Core ws://hôte:port] [-MockCore]` ou `./scripts/studio.sh` | Node.js 24 | | Windows, cuDNN pour l'inférence GPU | `.\scripts\install-cudnn.ps1 [-Version ] [-CudaMajor 12\|13] [-DryRun]` | CUDA Toolkit installé + GPU NVIDIA ; télécharge cuDNN (~1,3 Go), demande d'accepter la licence NVIDIA et définit `CUDNN_PATH` (utilisateur, sans droits admin) | Chaque script PowerShell affiche son aide avec `-Help`. diff --git a/docs/specs/protocole-studio-core-0.2.md b/docs/specs/protocole-studio-core-0.2.md index 75d1eaf..c0e8ef4 100644 --- a/docs/specs/protocole-studio-core-0.2.md +++ b/docs/specs/protocole-studio-core-0.2.md @@ -17,7 +17,7 @@ Ports par défaut : Core **9100**, interface servie par le host **9180**, faux C ## 2. Enveloppe et versions - Enveloppe : `{ "v": 1, "type": "", "id": ""?, "payload": { ... } }`. `v` reste 1. -- `id` (facultatif) : identifiant de requête choisi par le client. Toute réponse à une requête qui portait un `id` recopie cet `id`. Une requête sans `id` reçoit quand même sa réponse, sans `id`. +- `id` (facultatif) : identifiant de requête choisi par le client. Toute réponse à une requête qui portait un `id` recopie cet `id`. Une requête sans `id` reçoit quand même sa réponse, sans `id`. `hello` est envoyé sans `id` (le host et l'UI le font ainsi) ; `welcome` et les erreurs de poignée de main n'en portent pas. - Version du protocole : **`0.2.0`**. Compatibilité inchangée : même majeure et, en 0.x, même mineure. 0.1 et 0.2 sont donc incompatibles. - `payload` est toujours un objet (éventuellement vide `{}`). - Les entiers 64 bits (empreintes) sont transmis en **chaîne hexadécimale de 16 caractères minuscules**, jamais en nombre JSON (perte de précision en JavaScript au-delà de 2^53). @@ -128,7 +128,7 @@ Le Core est lancé depuis `/bin/sequa-core--, includeHashes: true }`. -5. Comparer les empreintes tick par tick : afficher « identique » ou « divergence au tick N », ainsi que `replayMs`. +5. Comparer les empreintes tick par tick : afficher « identique » ou « divergence au tick N », ainsi que `replayMs`. Si les deux tableaux n'ont pas la même longueur, la divergence est au premier tick absent de l'un des deux. 6. Mettre à jour le fichier d'exécution (nouveau `pid`, `instanceId`, `exePath`). `restart` fait la même chose avec le même exécutable, sans comparaison. diff --git a/studio/host/README.md b/studio/host/README.md new file mode 100644 index 0000000..425dec7 --- /dev/null +++ b/studio/host/README.md @@ -0,0 +1,29 @@ +# Studio host (`sequa-studio`) + +Face C++ de SEQUA Studio ([README de Studio](../README.md)) : l'exécutable que l'auteur lance. Il démarre ou rejoint le Core, le supervise, le recharge par rejeu de la session après recompilation, et sert l'interface ([ADR 0006](../../docs/adr/0006-studio-host-et-cycle-de-vie-du-core.md), [spécification §7](../../docs/specs/protocole-studio-core-0.2.md)). + +| Brique | Rôle | +|---|---| +| [supervisor/](supervisor) | Copie fantôme, lancement et suivi du processus Core, fichier d'exécution, journaux, surveillance de l'exécutable | +| [client/](client) | Client C++ du protocole 0.2 (requêtes corrélées, délais, empreintes) | +| [web/](web) | Service HTTP de `studio/ui/dist`, ouverture du navigateur | +| [app/](app) | `StudioHost` (orchestration, rechargement par rejeu) et l'exécutable `sequa-studio` | + +## Utilisation + +```powershell +.\scripts\build.ps1 # construit Core, host et tests +cd studio\ui; npm run build; cd ..\.. # construit l'interface servie +.\build\windows-msvc-debug\studio\host\app\sequa-studio.exe # Core + interface + navigateur +.\build\windows-msvc-debug\studio\host\app\sequa-studio.exe --dev # avec Vite (scripts\studio.ps1 dans un autre terminal) +.\build\windows-msvc-debug\studio\host\app\sequa-studio.exe --watch # recharge le Core à chaque recompilation +``` + +Commandes dans la console : `status`, `reload` (nouvel exécutable du Core, session rejouée et comparée tick par tick), `restart`, `detach` / `attach` (le Core survit ou non à Studio, réversible), `log`, `quit`. + +Par défaut le Core est **lié** à Studio : si Studio s'arrête ou plante, le Core s'arrête après un délai de grâce de 3 s. Aucun Core orphelin ne garde la mémoire ni, plus tard, la VRAM. + +## Licences + +- MPL 2.0. +- Dépendances : IXWebSocket 12.1.0 (BSD-3-Clause), nlohmann/json 3.12.0 (MIT). diff --git a/studio/host/app/CMakeLists.txt b/studio/host/app/CMakeLists.txt new file mode 100644 index 0000000..f3f2dac --- /dev/null +++ b/studio/host/app/CMakeLists.txt @@ -0,0 +1,28 @@ +# SPDX-License-Identifier: MPL-2.0 +# +# Exécutable sequa-studio : assemble supervisor, client et web (spec 0.2 §7, ADR 0006). + +add_library(sequa_host_app STATIC + src/options.cpp + src/studio_host.cpp) +target_include_directories(sequa_host_app PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}/src") +target_link_libraries(sequa_host_app PUBLIC sequa::host_supervisor sequa::host_client sequa::host_web) +sequa_target_defaults(sequa_host_app) + +add_executable(sequa_studio_app src/main.cpp) +set_target_properties(sequa_studio_app PROPERTIES OUTPUT_NAME sequa-studio) +target_link_libraries(sequa_studio_app PRIVATE sequa_host_app) +sequa_target_defaults(sequa_studio_app) + +if(SEQUA_BUILD_TESTS) + add_executable(sequa_host_app_tests + tests/main.cpp + tests/test_options.cpp + tests/test_studio_host.cpp) + target_link_libraries(sequa_host_app_tests PRIVATE sequa_host_app doctest::doctest) + # Test de bout en bout contre le vrai Core. + target_compile_definitions(sequa_host_app_tests PRIVATE SEQUA_TEST_CORE_EXE="$") + add_dependencies(sequa_host_app_tests sequa_core_app) + sequa_target_defaults(sequa_host_app_tests) + doctest_discover_tests(sequa_host_app_tests) +endif() diff --git a/studio/host/app/src/main.cpp b/studio/host/app/src/main.cpp new file mode 100644 index 0000000..01b3c8b --- /dev/null +++ b/studio/host/app/src/main.cpp @@ -0,0 +1,282 @@ +// SPDX-License-Identifier: MPL-2.0 +// +// sequa-studio : démarre ou rejoint le Core, sert l'interface, ouvre le navigateur, puis +// lit des commandes sur la console (spec 0.2 §7, ADR 0006). + +#include "options.hpp" +#include "studio_host.hpp" + +#include "sequa/studio/host/browser.hpp" +#include "sequa/studio/host/files.hpp" +#include "sequa/studio/host/paths.hpp" +#include "sequa/studio/host/ui_server.hpp" +#include "sequa/studio/host/urls.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#if defined(_WIN32) +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include +#endif + +namespace { + +using namespace sequa::studio::host; +using namespace std::chrono_literals; + +volatile std::sig_atomic_t interrupted = 0; + +extern "C" void on_signal(int /*signal*/) +{ + interrupted = 1; +} + +std::mutex output_mutex; + +void say(std::string_view line) +{ + const std::lock_guard lock(output_mutex); + std::printf("%.*s\n", static_cast(line.size()), line.data()); + std::fflush(stdout); +} + +// Lecture de la console dans un fil à part : la boucle principale reste libre pour +// surveiller l'exécutable du Core et la connexion. +class CommandReader { +public: + CommandReader() + { + std::thread([this] { + std::string line; + while (std::getline(std::cin, line)) { + const std::lock_guard lock(mutex_); + lines_.push_back(line); + } + const std::lock_guard lock(mutex_); + closed_ = true; + }).detach(); // getline n'est pas interruptible : le fil meurt avec le processus + } + + std::optional next() + { + const std::lock_guard lock(mutex_); + if (lines_.empty()) { + if (closed_) { + return std::string("quit"); + } + return std::nullopt; + } + std::string line = std::move(lines_.front()); + lines_.pop_front(); + return line; + } + +private: + std::mutex mutex_; + std::deque lines_; + bool closed_ = false; +}; + +void print_reload(const ReloadReport& report, const char* what) +{ + if (!report.ok) { + say(std::string(what) + " impossible : " + report.error); + return; + } + std::string line = std::string(what) + " : session reprise au tick " + std::to_string(report.tick) + " (rejeu " + + std::to_string(static_cast(report.replay_ms)) + " ms)"; + if (report.compared) { + line += report.divergence ? ", DIVERGENCE au tick " + std::to_string(*report.divergence) + : ", comportement identique"; + } + say(line + "."); +} + +void print_status(StudioHost& host) +{ + const auto core = host.request("core/status"); + const auto session = host.request("session/status"); + if (!core || !session) { + say("Core injoignable."); + return; + } + say("Core " + core.value->value("coreVersion", "?") + ", pid " + std::to_string(core.value->value("pid", 0)) + + ", instance " + core.value->value("instanceId", "?") + ", " + + (core.value->value("linked", false) ? "lié" : "détaché") + ", clients " + + std::to_string(core.value->value("clients", 0))); + say("Session : tick " + std::to_string(session.value->value("tick", 0)) + ", " + + (session.value->value("running", false) ? "en marche" : "en pause") + ", empreinte " + + session.value->value("stateHash", "?") + ", entrées " + std::to_string(session.value->value("inputCount", 0))); +} + +} // namespace + +int main(int argc, char** argv) +{ +#if defined(_WIN32) + SetConsoleOutputCP(CP_UTF8); +#endif + std::vector args(argv + 1, argv + argc); + const ParsedOptions parsed = parse_options(args); + if (!parsed.options) { + std::fprintf(stderr, "%s\n%s", parsed.error.c_str(), usage().c_str()); + return 2; + } + const HostOptions& options = *parsed.options; + if (options.help) { + std::printf("%s", usage().c_str()); + return 0; + } + + const auto exe_dir = current_exe_dir(); + const auto core_exe = options.core_exe.empty() ? find_core_exe(exe_dir) : std::optional(options.core_exe); + if (!core_exe) { + std::fprintf(stderr, "Exécutable du Core introuvable : préciser --core-exe.\n"); + return 2; + } + const auto run_dir = default_run_dir(); + if (!run_dir) { + std::fprintf(stderr, "Dossier d'exécution introuvable (variable d'environnement manquante).\n"); + return 1; + } + + // Origines autorisées côté Core : l'interface servie ici, ou le serveur Vite. + const std::string ui_base = "http://127.0.0.1:" + std::to_string(options.ui_port); + HostConfig config; + config.core_exe = *core_exe; + config.run_dir = *run_dir; + config.core_port = options.core_port; + config.core_console = options.core_console; + config.log = say; + config.allowed_origins = {ui_base, "http://localhost:" + std::to_string(options.ui_port)}; + if (options.dev_url) { + if (const auto origin = origin_of(*options.dev_url)) { + config.allowed_origins.push_back(*origin); + } + } + + StudioHost host(config); + if (const std::string error = host.start(); !error.empty()) { + std::fprintf(stderr, "%s\n", error.c_str()); + return 1; + } + if (options.detach_core) { + if (const std::string error = host.detach(); !error.empty()) { + say("Détachement impossible : " + error); + } else { + say("Bail levé : le Core survivra à Studio."); + } + } + + // Interface : servie ici, ou par Vite en mode développement. + std::optional ui; + std::string page_base = ui_base; + if (options.dev_url) { + page_base = *options.dev_url; + while (!page_base.empty() && page_base.back() == '/') { + page_base.pop_back(); + } + } else { + const auto ui_dir = options.ui_dir.empty() ? find_ui_dir(exe_dir) : std::optional(options.ui_dir); + if (!ui_dir) { + say("Interface introuvable (studio/ui/dist) : lancer « npm run build » dans studio/ui, ou utiliser --dev."); + } else { + UiServerOptions ui_options; + ui_options.port = options.ui_port; + ui_options.root = *ui_dir; + ui.emplace(ui_options); + if (const std::string error = ui->start(); !error.empty()) { + say("Interface non servie : " + error); + ui.reset(); + } + } + } + const std::string page = build_ui_url(page_base, host.core_url()); + say("Studio : " + page_base + " (Core ws://" + host.run().host + ":" + std::to_string(host.run().port) + ")"); + if (!options.no_browser && (ui || options.dev_url) && !open_browser(page)) { + say("Navigateur non ouvert : ouvrir l'adresse à la main."); + } + say("Commandes : status, restart, reload, detach, attach, log, quit, help."); + + std::signal(SIGINT, on_signal); + std::signal(SIGTERM, on_signal); + CommandReader commands; + FileWatcher watcher(*core_exe); + bool warned_disconnect = false; + + for (;;) { + if (interrupted != 0) { + break; + } + if (watcher.poll()) { + if (options.watch) { + say("Nouvel exécutable du Core détecté : rechargement..."); + print_reload(host.reload(), "Rechargement"); + } else { + say("Nouvel exécutable du Core détecté : taper « reload » pour le charger en gardant la session."); + } + } + if (!host.connected() && !warned_disconnect) { + warned_disconnect = true; + say("Connexion au Core perdue. « restart » pour relancer (la session en mémoire est perdue), « log » pour le journal."); + } + + const auto line = commands.next(); + if (!line) { + std::this_thread::sleep_for(100ms); + continue; + } + const std::string& command = *line; + if (command.empty()) { + continue; + } + if (command == "quit" || command == "exit") { + break; + } + if (command == "help") { + say(usage()); + } else if (command == "status") { + print_status(host); + } else if (command == "reload") { + print_reload(host.reload(), "Rechargement"); + warned_disconnect = false; + } else if (command == "restart") { + if (host.connected()) { + print_reload(host.restart(), "Redémarrage"); + } else { + const std::string error = host.start(); + say(error.empty() ? "Core relancé (nouvelle session)." : "Relance impossible : " + error); + } + warned_disconnect = false; + } else if (command == "detach") { + const std::string error = host.detach(); + say(error.empty() ? "Bail levé : le Core survivra à Studio." : "Détachement impossible : " + error); + } else if (command == "attach") { + const std::string error = host.attach(); + say(error.empty() ? "Bail rétabli : le Core s'arrêtera avec Studio." : "Rattachement impossible : " + error); + } else if (command == "log") { + for (const std::string& entry : host.log_tail(20)) { + say(" " + entry); + } + } else { + say("Commande inconnue : " + command + " (help)"); + } + } + + if (ui) { + ui->stop(); + } + host.shutdown(); + return 0; +} diff --git a/studio/host/app/src/options.cpp b/studio/host/app/src/options.cpp new file mode 100644 index 0000000..fad57f9 --- /dev/null +++ b/studio/host/app/src/options.cpp @@ -0,0 +1,178 @@ +// SPDX-License-Identifier: MPL-2.0 +#include "options.hpp" + +#include +#include +#include + +#if defined(_WIN32) +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include +#elif defined(__APPLE__) +#include +#else +#include +#endif + +namespace sequa::studio::host { + +namespace { + +#if defined(_WIN32) +constexpr const char* core_name = "sequa-core.exe"; +#else +constexpr const char* core_name = "sequa-core"; +#endif + +std::optional parse_port(std::string_view text) +{ + unsigned value = 0; + const auto [next, ec] = std::from_chars(text.data(), text.data() + text.size(), value); + if (ec != std::errc{} || next != text.data() + text.size() || value == 0 || value > 65535) { + return std::nullopt; + } + return static_cast(value); +} + +bool is_file(const std::filesystem::path& path) +{ + std::error_code ec; + return std::filesystem::is_regular_file(path, ec); +} + +} // namespace + +std::string usage() +{ + return "Usage : sequa-studio [options]\n" + " --core-exe exécutable du Core (défaut : trouvé à côté ou dans build//core)\n" + " --core-port port du Core (défaut 9100)\n" + " --ui-port port de l'interface servie (défaut 9180)\n" + " --ui-dir interface construite (défaut : studio/ui/dist)\n" + " --dev [url] n'ouvre que le serveur Vite (défaut http://localhost:5173)\n" + " --detach-core le Core survivra à Studio (bail levé)\n" + " --no-browser n'ouvre pas le navigateur\n" + " --core-console Core dans sa propre console (sortie non capturée)\n" + " --watch recharge le Core quand son exécutable change\n" + " --help\n" + "Commandes : status, restart, reload, detach, attach, log, quit, help\n"; +} + +ParsedOptions parse_options(std::span args) +{ + HostOptions options; + for (std::size_t i = 0; i < args.size(); ++i) { + const std::string_view arg = args[i]; + const bool has_value = i + 1 < args.size(); + const auto fail = [](std::string message) { return ParsedOptions{std::nullopt, std::move(message)}; }; + if (arg == "--help" || arg == "-h") { + options.help = true; + } else if (arg == "--detach-core") { + options.detach_core = true; + } else if (arg == "--no-browser") { + options.no_browser = true; + } else if (arg == "--core-console") { + options.core_console = true; + } else if (arg == "--watch") { + options.watch = true; + } else if (arg == "--dev") { + // Valeur facultative : prise seulement si elle ressemble à une URL. + if (has_value && (args[i + 1].starts_with("http://") || args[i + 1].starts_with("https://"))) { + options.dev_url = std::string(args[++i]); + } else { + options.dev_url = "http://localhost:5173"; + } + } else if (arg == "--core-exe" && has_value) { + options.core_exe = std::filesystem::path(std::u8string(args[i + 1].begin(), args[i + 1].end())); + ++i; + } else if (arg == "--ui-dir" && has_value) { + options.ui_dir = std::filesystem::path(std::u8string(args[i + 1].begin(), args[i + 1].end())); + ++i; + } else if ((arg == "--core-port" || arg == "--ui-port") && has_value) { + const auto port = parse_port(args[++i]); + if (!port) { + return fail("Port invalide : " + std::string(args[i])); + } + (arg == "--core-port" ? options.core_port : options.ui_port) = *port; + } else { + return fail("Argument inconnu ou incomplet : " + std::string(arg)); + } + } + if (options.core_port == options.ui_port) { + return {std::nullopt, "Le Core et l'interface ne peuvent pas partager le même port."}; + } + return {options, {}}; +} + +std::optional find_core_exe(const std::filesystem::path& exe_dir) +{ + if (is_file(exe_dir / core_name)) { + return exe_dir / core_name; + } + for (std::filesystem::path dir = exe_dir; dir.has_parent_path() && dir != dir.parent_path(); + dir = dir.parent_path()) { + if (is_file(dir / "core" / core_name)) { + return dir / "core" / core_name; + } + } + return std::nullopt; +} + +std::optional find_ui_dir(const std::filesystem::path& exe_dir) +{ + for (std::filesystem::path dir = exe_dir; dir.has_parent_path() && dir != dir.parent_path(); + dir = dir.parent_path()) { + const auto candidate = dir / "studio" / "ui" / "dist"; + if (is_file(candidate / "index.html")) { + return candidate; + } + } + return std::nullopt; +} + +std::optional origin_of(std::string_view url) +{ + std::size_t scheme_end = 0; + if (url.starts_with("http://")) { + scheme_end = 7; + } else if (url.starts_with("https://")) { + scheme_end = 8; + } else { + return std::nullopt; + } + const std::size_t path = url.find_first_of("/?#", scheme_end); + const std::string_view origin = url.substr(0, path); + if (origin.size() == scheme_end) { + return std::nullopt; + } + return std::string(origin); +} + +std::filesystem::path current_exe_dir() +{ +#if defined(_WIN32) + std::wstring buffer(MAX_PATH, L'\0'); + for (;;) { + const DWORD length = GetModuleFileNameW(nullptr, buffer.data(), static_cast(buffer.size())); + if (length < buffer.size()) { + buffer.resize(length); + break; + } + buffer.resize(buffer.size() * 2); + } + return std::filesystem::path(buffer).parent_path(); +#elif defined(__APPLE__) + std::uint32_t size = 0; + _NSGetExecutablePath(nullptr, &size); + std::string buffer(size, '\0'); + _NSGetExecutablePath(buffer.data(), &size); + return std::filesystem::weakly_canonical(buffer.c_str()).parent_path(); +#else + std::error_code ec; + return std::filesystem::read_symlink("/proc/self/exe", ec).parent_path(); +#endif +} + +} // namespace sequa::studio::host diff --git a/studio/host/app/src/options.hpp b/studio/host/app/src/options.hpp new file mode 100644 index 0000000..b8c5d97 --- /dev/null +++ b/studio/host/app/src/options.hpp @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: MPL-2.0 +#pragma once + +#include +#include +#include +#include +#include +#include + +namespace sequa::studio::host { + +// Options de sequa-studio (spec 0.2 §7.1). +struct HostOptions { + std::filesystem::path core_exe; // vide : recherche automatique + std::uint16_t core_port = 9100; + std::uint16_t ui_port = 9180; + std::filesystem::path ui_dir; // vide : recherche automatique + std::optional dev_url; // --dev [url] + bool detach_core = false; + bool no_browser = false; + bool core_console = false; + bool watch = false; + bool help = false; +}; + +struct ParsedOptions { + std::optional options; + std::string error; // non vide si options est vide +}; + +[[nodiscard]] ParsedOptions parse_options(std::span args); +[[nodiscard]] std::string usage(); + +// Exécutable du Core : à côté de sequa-studio, sinon /core/sequa-core[.exe] +// en remontant depuis le dossier de sequa-studio (arborescence de build CMake). +[[nodiscard]] std::optional find_core_exe(const std::filesystem::path& exe_dir); + +// Interface construite : /studio/ui/dist contenant index.html. +[[nodiscard]] std::optional find_ui_dir(const std::filesystem::path& exe_dir); + +// Origine (schéma://hôte:port) d'une URL http(s), pour --allowed-origin du Core. +[[nodiscard]] std::optional origin_of(std::string_view url); + +// Dossier de l'exécutable courant. +[[nodiscard]] std::filesystem::path current_exe_dir(); + +} // namespace sequa::studio::host diff --git a/studio/host/app/src/studio_host.cpp b/studio/host/app/src/studio_host.cpp new file mode 100644 index 0000000..5fa63e2 --- /dev/null +++ b/studio/host/app/src/studio_host.cpp @@ -0,0 +1,308 @@ +// SPDX-License-Identifier: MPL-2.0 +#include "studio_host.hpp" + +#include "sequa/studio/host/files.hpp" +#include "sequa/studio/host/paths.hpp" +#include "sequa/studio/host/random.hpp" +#include "sequa/studio/host/shadow.hpp" +#include "sequa/studio/host/time.hpp" +#include "sequa/studio/host/urls.hpp" +#include "sequa/studio/host/wire.hpp" + +#include +#include + +namespace sequa::studio::host { + +namespace { + +using namespace std::chrono_literals; +using nlohmann::json; + +std::string describe(const Error& error) +{ + return error.server_code.empty() ? error.message : error.server_code + " : " + error.message; +} + +} // namespace + +StudioHost::StudioHost(HostConfig config) : config_(std::move(config)) {} + +StudioHost::~StudioHost() +{ + if (client_) { + client_->close(); + } +} + +void StudioHost::say(const std::string& message) const +{ + if (config_.log) { + config_.log(message); + } +} + +bool StudioHost::connected() const +{ + return client_ && client_->connected(); +} + +std::string StudioHost::core_url() const +{ + return build_core_ws_url(run_.host, run_.port, run_.token); +} + +RequestResult StudioHost::request(const std::string& type, const json& payload, std::chrono::milliseconds timeout) +{ + if (!client_) { + return {std::nullopt, Error{ErrorCode::NotConnected, "Pas de connexion au Core.", {}, 0}}; + } + return client_->request(type, payload, timeout); +} + +std::vector StudioHost::log_tail(std::size_t lines) const +{ + return read_last_lines(log_file_path(config_.run_dir, config_.core_port), lines); +} + +std::string StudioHost::connect_and_attach(const std::string& token, bool require_instance, + const std::string& expected_instance) +{ + auto client = std::make_unique(); + const ConnectResult connection = + client->connect(build_core_ws_url(config_.host, config_.core_port, token), 2s); + if (!connection) { + return describe(*connection.error); + } + if (require_instance && connection.value->instance_id != expected_instance) { + return "instanceId différent : ce n'est pas le Core du fichier d'exécution"; + } + const RequestResult attached = client->request("core/attach", json::object(), 5s); + if (!attached) { + return describe(*attached.error); + } + client_ = std::move(client); + linked_ = true; + run_.instanceId = connection.value->instance_id; + run_.pid = static_cast(connection.value->pid); + return {}; +} + +std::string StudioHost::start() +{ + if (!ensure_run_dirs(config_.run_dir)) { + return "Dossier d'exécution inaccessible : " + config_.run_dir.string(); + } + const auto run_path = run_file_path(config_.run_dir, config_.core_port); + + // 1. Rattachement à un Core existant (§7.2), validé par la poignée de main. + if (const auto existing = read_run_file(run_path); existing && existing->port == config_.core_port) { + run_ = *existing; + const std::string error = connect_and_attach(existing->token, true, existing->instanceId); + if (error.empty()) { + attached_existing_ = true; + say("Core existant rejoint (pid " + std::to_string(run_.pid) + ", instance " + run_.instanceId + ")."); + return {}; + } + if (error.starts_with("controller-exists")) { + return "Ce Core est déjà contrôlé par un autre Studio (port " + std::to_string(config_.core_port) + ")."; + } + say("Fichier d'exécution périmé (" + error + ") : supprimé."); + (void)remove_run_file(run_path); + run_ = RunFile{}; + } + + // 2. Nouveau Core. + const auto token = random_hex(16); + if (!token) { + return "Générateur aléatoire indisponible."; + } + run_.token = *token; + return launch_core(); +} + +std::string StudioHost::launch_core() +{ + const auto bin = bin_dir(config_.run_dir); + (void)cleanup_shadow_copies(bin); + const auto copy = make_shadow_copy(config_.core_exe, bin); + if (!copy) { + return "Copie de l'exécutable du Core impossible : " + config_.core_exe.string(); + } + + std::vector args = {"--host", config_.host, "--port", std::to_string(config_.core_port), + "--token", run_.token, "--linked"}; + for (const std::string& origin : config_.allowed_origins) { + args.insert(args.end(), {"--allowed-origin", origin}); + } + LaunchOptions launch; + launch.own_console = config_.core_console; + launch.log_path = log_file_path(config_.run_dir, config_.core_port); + process_ = CoreProcess::launch(*copy, args, launch); + if (!process_) { + return "Lancement du Core impossible : " + copy->string(); + } + + // Attente du port puis poignée de main et bail. + const auto deadline = std::chrono::steady_clock::now() + config_.startup_timeout; + std::string last_error; + for (;;) { + last_error = connect_and_attach(run_.token, false, {}); + if (last_error.empty()) { + break; + } + if (const auto code = process_->wait_for(0ms)) { + std::string tail; + for (const std::string& line : log_tail(5)) { + tail += "\n " + line; + } + process_.reset(); + return "Le Core s'est arrêté au démarrage (code " + std::to_string(*code) + ")." + tail; + } + if (std::chrono::steady_clock::now() >= deadline) { + process_->terminate(); + process_.reset(); + return "Le Core ne répond pas : " + last_error; + } + std::this_thread::sleep_for(100ms); + } + + run_.host = config_.host; + run_.port = config_.core_port; + run_.exePath = *copy; + run_.sourceExePath = config_.core_exe; + run_.startedAt = utc_now_iso8601(); + attached_existing_ = false; + if (!write_run_file(run_file_path(config_.run_dir, config_.core_port), run_)) { + say("Attention : fichier d'exécution non écrit (rattachement ultérieur impossible)."); + } + say("Core lancé (pid " + std::to_string(run_.pid) + ", instance " + run_.instanceId + ", " + + run_.exePath.filename().string() + ")."); + return {}; +} + +void StudioHost::stop_core() +{ + if (client_) { + (void)client_->request("core/shutdown", json::object(), 2s); + client_->close(); + client_.reset(); + } + if (process_) { + if (!process_->wait_for(3s)) { + say("Le Core ne s'arrête pas : arrêt forcé."); + process_->terminate(); + } + process_.reset(); + return; + } + // Core rejoint : pas de handle, on attend la disparition du PID (validé par instanceId). + for (int i = 0; i < 30 && is_process_alive(run_.pid); ++i) { + std::this_thread::sleep_for(100ms); + } +} + +ReloadReport StudioHost::replace_core(bool compare) +{ + ReloadReport report; + if (!client_) { + report.error = "Pas de connexion au Core."; + return report; + } + + // 1. Exporter la session (enregistrement + empreintes) et noter marche/pause. + const RequestResult status = client_->request("session/status", json::object(), 5s); + const RequestResult exported = client_->request("session/export", json{{"includeHashes", true}}, 30s); + if (!status || !exported) { + report.error = "Export impossible : " + describe(status ? *exported.error : *status.error); + return report; + } + const bool running = status.value->value("running", true); + const std::string recording = exported.value->value("recording", ""); + const auto old_hashes = decode_hashes(exported.value->value("hashes", "")); + if (!old_hashes) { + report.error = "Empreintes de l'ancien Core illisibles."; + return report; + } + + // 2-3. Arrêter l'ancien Core, lancer le nouveau (mêmes port et jeton). + const bool was_linked = linked_; + stop_core(); + if (const std::string error = launch_core(); !error.empty()) { + report.error = error; + return report; + } + + // 4. Réimporter : rejeu jusqu'au même tick. + const RequestResult imported = client_->request( + "session/import", json{{"recording", recording}, {"resume", running}, {"includeHashes", true}}, 120s); + if (!imported) { + report.error = "Import impossible : " + describe(*imported.error); + return report; + } + report.tick = imported.value->value("tick", std::uint64_t{0}); + report.replay_ms = imported.value->value("replayMs", 0.0); + + // 5. Comparer les empreintes tick par tick. + if (compare) { + const auto new_hashes = decode_hashes(imported.value->value("hashes", "")); + if (!new_hashes) { + report.error = "Empreintes du nouveau Core illisibles."; + return report; + } + report.compared = true; + report.divergence = first_divergence(*old_hashes, *new_hashes); + } + if (!was_linked) { + (void)detach(); + } + report.ok = true; + return report; +} + +ReloadReport StudioHost::reload() +{ + return replace_core(true); +} + +ReloadReport StudioHost::restart() +{ + return replace_core(false); +} + +std::string StudioHost::detach() +{ + const RequestResult result = request("core/detach"); + if (!result) { + return describe(*result.error); + } + linked_ = false; + return {}; +} + +std::string StudioHost::attach() +{ + const RequestResult result = request("core/attach"); + if (!result) { + return describe(*result.error); + } + linked_ = true; + return {}; +} + +void StudioHost::shutdown() +{ + if (!linked_) { + if (client_) { + client_->close(); + client_.reset(); + } + say("Core détaché : il continue (pid " + std::to_string(run_.pid) + ")."); + return; + } + stop_core(); + (void)remove_run_file(run_file_path(config_.run_dir, config_.core_port)); + say("Core arrêté."); +} + +} // namespace sequa::studio::host diff --git a/studio/host/app/src/studio_host.hpp b/studio/host/app/src/studio_host.hpp new file mode 100644 index 0000000..7289f93 --- /dev/null +++ b/studio/host/app/src/studio_host.hpp @@ -0,0 +1,98 @@ +// SPDX-License-Identifier: MPL-2.0 +#pragma once + +#include "sequa/studio/host/client.hpp" +#include "sequa/studio/host/process.hpp" +#include "sequa/studio/host/run_file.hpp" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace sequa::studio::host { + +struct HostConfig { + std::filesystem::path core_exe; // exécutable SOURCE du Core (jamais lancé directement) + std::filesystem::path run_dir; // dossier d'exécution (§7.4) + std::string host = "127.0.0.1"; + std::uint16_t core_port = 9100; + std::vector allowed_origins; // transmis au Core (--allowed-origin) + bool core_console = false; + std::chrono::milliseconds startup_timeout{10000}; + std::function log; // messages pour l'utilisateur +}; + +// Résultat d'un rechargement ou d'un redémarrage (§7.6). +struct ReloadReport { + bool ok = false; + std::string error; + std::uint64_t tick = 0; // tick atteint par le rejeu + bool compared = false; // empreintes comparées (reload) + std::optional divergence; // premier tick divergent ; vide = identique + double replay_ms = 0; +}; + +// Orchestrateur du Studio host : démarre ou rejoint le Core, le pilote par le protocole, +// le recharge par rejeu. Utilisé depuis un seul fil (la boucle de commandes). +class StudioHost { +public: + explicit StudioHost(HostConfig config); + ~StudioHost(); // ferme la connexion ; le bail décide du sort du Core + + StudioHost(const StudioHost&) = delete; + StudioHost& operator=(const StudioHost&) = delete; + + // Rattachement via le fichier d'exécution s'il désigne un Core vivant (même + // instanceId), sinon lancement d'un nouveau Core. Chaîne vide si OK. + [[nodiscard]] std::string start(); + + [[nodiscard]] bool attached_existing() const noexcept { return attached_existing_; } + [[nodiscard]] bool linked() const noexcept { return linked_; } + [[nodiscard]] bool connected() const; + [[nodiscard]] const RunFile& run() const noexcept { return run_; } + // URL WebSocket du Core, jeton inclus (pour l'interface). + [[nodiscard]] std::string core_url() const; + + // Rechargement avec l'exécutable source courant et comparaison des empreintes. + [[nodiscard]] ReloadReport reload(); + // Redémarrage avec le même exécutable source, sans comparaison. + [[nodiscard]] ReloadReport restart(); + + [[nodiscard]] std::string detach(); // lève le bail : le Core survivra à Studio + [[nodiscard]] std::string attach(); // rétablit le bail + + // Requête brute (commandes, tests). + [[nodiscard]] RequestResult request(const std::string& type, const nlohmann::json& payload = nlohmann::json::object(), + std::chrono::milliseconds timeout = std::chrono::seconds(5)); + + [[nodiscard]] std::vector log_tail(std::size_t lines) const; + + // Fin de Studio : Core lié -> core/shutdown, attente, fichier d'exécution supprimé ; + // Core détaché -> la connexion est seulement fermée, le Core et son fichier restent. + void shutdown(); + +private: + [[nodiscard]] std::string launch_core(); + [[nodiscard]] std::string connect_and_attach(const std::string& token, bool require_instance, + const std::string& expected_instance); + void stop_core(); + [[nodiscard]] ReloadReport replace_core(bool compare); + void say(const std::string& message) const; + + HostConfig config_; + std::unique_ptr client_; + std::optional process_; // vide si le Core a été rejoint (lancé par un autre Studio) + RunFile run_; + bool attached_existing_ = false; + bool linked_ = true; +}; + +} // namespace sequa::studio::host diff --git a/studio/host/app/tests/main.cpp b/studio/host/app/tests/main.cpp new file mode 100644 index 0000000..ecb715c --- /dev/null +++ b/studio/host/app/tests/main.cpp @@ -0,0 +1,3 @@ +// SPDX-License-Identifier: MPL-2.0 +#define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN +#include diff --git a/studio/host/app/tests/test_options.cpp b/studio/host/app/tests/test_options.cpp new file mode 100644 index 0000000..f9c5326 --- /dev/null +++ b/studio/host/app/tests/test_options.cpp @@ -0,0 +1,99 @@ +// SPDX-License-Identifier: MPL-2.0 +#include "options.hpp" + +#include + +#include +#include +#include +#include + +using namespace sequa::studio::host; + +namespace { + +ParsedOptions parse(std::vector args) +{ + return parse_options(args); +} + +} // namespace + +TEST_CASE("Options : valeurs par defaut") +{ + const auto parsed = parse({}); + REQUIRE(parsed.options.has_value()); + CHECK(parsed.options->core_port == 9100); + CHECK(parsed.options->ui_port == 9180); + CHECK_FALSE(parsed.options->dev_url.has_value()); + CHECK_FALSE(parsed.options->watch); +} + +TEST_CASE("Options : toutes les options") +{ + const auto parsed = parse({"--core-exe", "C:/x/sequa-core.exe", "--core-port", "9200", "--ui-port", "9280", + "--dev", "http://localhost:5174", "--detach-core", "--no-browser", "--core-console", + "--watch", "--ui-dir", "dist"}); + REQUIRE(parsed.options.has_value()); + const auto& options = *parsed.options; + CHECK(options.core_exe == std::filesystem::path("C:/x/sequa-core.exe")); + CHECK(options.core_port == 9200); + CHECK(options.ui_port == 9280); + CHECK(options.dev_url == "http://localhost:5174"); + CHECK(options.detach_core); + CHECK(options.no_browser); + CHECK(options.core_console); + CHECK(options.watch); + CHECK(options.ui_dir == std::filesystem::path("dist")); +} + +TEST_CASE("Options : --dev sans URL") +{ + const auto parsed = parse({"--dev", "--watch"}); + REQUIRE(parsed.options.has_value()); + CHECK(parsed.options->dev_url == "http://localhost:5173"); + CHECK(parsed.options->watch); +} + +TEST_CASE("Options : erreurs") +{ + CHECK_FALSE(parse({"--core-port", "0"}).options.has_value()); + CHECK_FALSE(parse({"--core-port", "70000"}).options.has_value()); + CHECK_FALSE(parse({"--core-port", "91a"}).options.has_value()); + CHECK_FALSE(parse({"--core-port"}).options.has_value()); + CHECK_FALSE(parse({"--inconnue"}).options.has_value()); + CHECK_FALSE(parse({"--core-port", "9180"}).options.has_value()); // même port que l'interface +} + +TEST_CASE("Options : origine d'une URL") +{ + CHECK(origin_of("http://localhost:5173/") == "http://localhost:5173"); + CHECK(origin_of("https://exemple.com:8443/a?b#c") == "https://exemple.com:8443"); + CHECK(origin_of("http://127.0.0.1:9180") == "http://127.0.0.1:9180"); + CHECK_FALSE(origin_of("ws://127.0.0.1:9100").has_value()); + CHECK_FALSE(origin_of("http://").has_value()); +} + +TEST_CASE("Options : recherche du Core et de l'interface dans une arborescence de build") +{ + const auto root = std::filesystem::temp_directory_path() / "sequa-app-options-test"; + std::filesystem::remove_all(root); +#if defined(_WIN32) + const char* core_name = "sequa-core.exe"; +#else + const char* core_name = "sequa-core"; +#endif + const auto exe_dir = root / "build" / "preset" / "studio" / "host" / "app"; + std::filesystem::create_directories(exe_dir); + std::filesystem::create_directories(root / "build" / "preset" / "core"); + std::filesystem::create_directories(root / "studio" / "ui" / "dist"); + std::ofstream(root / "build" / "preset" / "core" / core_name) << "x"; + std::ofstream(root / "studio" / "ui" / "dist" / "index.html") << ""; + + CHECK(find_core_exe(exe_dir) == root / "build" / "preset" / "core" / core_name); + CHECK(find_ui_dir(exe_dir) == root / "studio" / "ui" / "dist"); + + std::ofstream(exe_dir / core_name) << "x"; // à côté : prioritaire (distribution) + CHECK(find_core_exe(exe_dir) == exe_dir / core_name); + std::filesystem::remove_all(root); +} diff --git a/studio/host/app/tests/test_studio_host.cpp b/studio/host/app/tests/test_studio_host.cpp new file mode 100644 index 0000000..2002da9 --- /dev/null +++ b/studio/host/app/tests/test_studio_host.cpp @@ -0,0 +1,165 @@ +// SPDX-License-Identifier: MPL-2.0 +// +// Bout en bout : StudioHost pilote le VRAI sequa-core (spec 0.2 §7, ADR 0006), dans un +// dossier d'exécution temporaire (jamais celui de l'utilisateur). +#include "studio_host.hpp" + +#include "sequa/studio/host/paths.hpp" +#include "sequa/studio/host/random.hpp" +#include "sequa/studio/host/run_file.hpp" + +#include + +#include +#include +#include +#include + +using namespace sequa::studio::host; +using namespace std::chrono_literals; + +namespace { + +const std::filesystem::path core_exe = SEQUA_TEST_CORE_EXE; + +struct RunDir { + RunDir() : path(std::filesystem::temp_directory_path() / ("sequa-host-e2e-" + random_hex(6).value())) {} + ~RunDir() + { + std::error_code ignored; + std::filesystem::remove_all(path, ignored); + } + std::filesystem::path path; +}; + +HostConfig config_for(const RunDir& dir, std::uint16_t port) +{ + HostConfig config; + config.core_exe = core_exe; + config.run_dir = dir.path; + config.core_port = port; + config.allowed_origins = {"http://127.0.0.1:9180"}; + return config; +} + +// Démarre un host sur le premier port libre d'une plage réservée à ces tests. +std::unique_ptr start_host(const RunDir& dir, std::uint16_t& port) +{ + for (port = 19500; port < 19540; ++port) { + auto host = std::make_unique(config_for(dir, port)); + if (host->start().empty()) { + return host; + } + } + FAIL("aucun port libre entre 19500 et 19539"); + return nullptr; +} + +std::uint64_t tick_of(StudioHost& host) +{ + const auto status = host.request("session/status"); + REQUIRE(status.ok()); + return status.value->value("tick", std::uint64_t{0}); +} + +bool wait_dead(std::uint32_t pid, std::chrono::milliseconds timeout) +{ + const auto deadline = std::chrono::steady_clock::now() + timeout; + while (is_process_alive(pid)) { + if (std::chrono::steady_clock::now() >= deadline) { + return false; + } + std::this_thread::sleep_for(50ms); + } + return true; +} + +} // namespace + +TEST_CASE("StudioHost : lancement puis rechargement par rejeu, session conservee") +{ + RunDir dir; + std::uint16_t port = 0; + auto host = start_host(dir, port); + REQUIRE(host); + CHECK_FALSE(host->attached_existing()); + CHECK(host->linked()); + CHECK(host->core_url().find("?token=") != std::string::npos); + const auto first = host->run(); + CHECK(read_run_file(run_file_path(dir.path, port)) == first); + CHECK(first.exePath.parent_path() == bin_dir(dir.path)); // lancé depuis une copie fantôme + + // Un peu de jeu : courir et sauter pendant que la session tourne à 60 Hz. + REQUIRE(host->request("session/input", {{"buttons", 0}, {"moveX", 32767}, {"moveY", 0}}).ok()); + std::this_thread::sleep_for(300ms); + REQUIRE(host->request("session/input", {{"buttons", 1}, {"moveX", 32767}, {"moveY", 0}}).ok()); + std::this_thread::sleep_for(200ms); + const std::uint64_t before = tick_of(*host); + CHECK(before > 10); + + const ReloadReport report = host->reload(); + INFO("erreur : " << report.error); + REQUIRE(report.ok); + CHECK(report.compared); + CHECK_FALSE(report.divergence.has_value()); // même code : comportement identique + CHECK(report.tick >= before); + + const auto second = host->run(); + CHECK(second.pid != first.pid); + CHECK(second.instanceId != first.instanceId); + CHECK(second.token == first.token); + CHECK(wait_dead(first.pid, 3s)); // l'ancien Core est bien arrêté + CHECK(read_run_file(run_file_path(dir.path, port)) == second); + + // La session continue dans le nouveau Core. + std::this_thread::sleep_for(200ms); + CHECK(tick_of(*host) > report.tick); + + host->shutdown(); + CHECK(wait_dead(second.pid, 3s)); + CHECK_FALSE(std::filesystem::exists(run_file_path(dir.path, port))); +} + +TEST_CASE("StudioHost : detachement reversible, rattachement par un nouveau Studio") +{ + RunDir dir; + std::uint16_t port = 0; + auto first = start_host(dir, port); + REQUIRE(first); + const auto run = first->run(); + REQUIRE(first->detach().empty()); + CHECK_FALSE(first->linked()); + first->shutdown(); // détaché : seule la connexion est fermée + first.reset(); + + std::this_thread::sleep_for(3500ms); // au-delà du délai de grâce du bail (3 s) + CHECK(is_process_alive(run.pid)); + + StudioHost second(config_for(dir, port)); + REQUIRE(second.start().empty()); + CHECK(second.attached_existing()); + CHECK(second.run().instanceId == run.instanceId); + CHECK(second.linked()); // le rattachement rétablit le bail + + second.shutdown(); + CHECK(wait_dead(run.pid, 3s)); + CHECK_FALSE(std::filesystem::exists(run_file_path(dir.path, port))); +} + +TEST_CASE("StudioHost : Studio disparu sans arret propre -> le Core lie s'arrete seul") +{ + RunDir dir; + std::uint16_t port = 0; + auto host = start_host(dir, port); + REQUIRE(host); + const auto pid = host->run().pid; + host.reset(); // comme un plantage : aucune demande d'arrêt + CHECK(wait_dead(pid, 6s)); // délai de grâce 3 s, puis arrêt (code 3) + + // Le fichier d'exécution est resté : le Studio suivant le détecte périmé. + REQUIRE(std::filesystem::exists(run_file_path(dir.path, port))); + StudioHost next(config_for(dir, port)); + REQUIRE(next.start().empty()); + CHECK_FALSE(next.attached_existing()); + next.shutdown(); +} diff --git a/studio/ui/src/model/state.ts b/studio/ui/src/model/state.ts index 83ed702..a33310b 100644 --- a/studio/ui/src/model/state.ts +++ b/studio/ui/src/model/state.ts @@ -6,7 +6,7 @@ * 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 const PROTOCOL_VERSION = '0.2.0'; export type ConnectionStatus = 'disconnected' | 'connecting' | 'connected'; diff --git a/studio/ui/src/protocol/websocket.ts b/studio/ui/src/protocol/websocket.ts index a4b4131..3adb7c4 100644 --- a/studio/ui/src/protocol/websocket.ts +++ b/studio/ui/src/protocol/websocket.ts @@ -44,6 +44,15 @@ function createBrowserSocket(url: string): WebSocketLike { return new WebSocket(url) as unknown as WebSocketLike; } +/** + * URL affichable : sans la partie requête, qui porte le jeton du Core + * (`?token=...`, spec 0.2 §3). Le jeton ne doit jamais apparaître dans l'interface. + */ +function withoutQuery(url: string): string { + const query = url.indexOf('?'); + return query === -1 ? url : url.slice(0, query); +} + function describeError(error: unknown): string { return error instanceof Error ? error.message : String(error); } @@ -84,7 +93,7 @@ export class WebSocketCoreConnection implements CoreConnection { public constructor(url: string, options: WebSocketCoreConnectionOptions = {}) { this.url = url; - this.label = `Core ${url}`; + this.label = `Core ${withoutQuery(url)}`; this.createSocket = options.createSocket ?? createBrowserSocket; this.handshakeTimeoutMs = options.handshakeTimeoutMs ?? DEFAULT_HANDSHAKE_TIMEOUT_MS; } diff --git a/studio/ui/tests/messages.test.ts b/studio/ui/tests/messages.test.ts index 62c1ffe..d638459 100644 --- a/studio/ui/tests/messages.test.ts +++ b/studio/ui/tests/messages.test.ts @@ -140,15 +140,15 @@ describe('decode', () => { 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); + expect(isCompatibleProtocolVersion('0.2.0')).toBe(true); + expect(isCompatibleProtocolVersion('0.2.5')).toBe(true); + expect(isCompatibleProtocolVersion('0.2.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.1.0')).toBe(false); expect(isCompatibleProtocolVersion('0.9.2')).toBe(false); - expect(isCompatibleProtocolVersion('0.2.0-alpha.1')).toBe(false); + expect(isCompatibleProtocolVersion('0.3.0-alpha.1')).toBe(false); }); it('refuse une autre majeure', () => { diff --git a/studio/ui/tests/websocket.test.ts b/studio/ui/tests/websocket.test.ts index d3968a2..153ceb2 100644 --- a/studio/ui/tests/websocket.test.ts +++ b/studio/ui/tests/websocket.test.ts @@ -155,6 +155,9 @@ describe('WebSocketCoreConnection', () => { expect(connection.simulated).toBe(false); expect(connection.label).toBe(`Core ${CORE_URL}`); + // Le jeton du Core n'apparaît jamais dans le libellé affiché. + const withToken = new WebSocketCoreConnection(`${CORE_URL}?token=secret123`); + expect(withToken.label).toBe(`Core ${CORE_URL}`); }); it('émet connecting, envoie hello puis émet connected après welcome', () => {