From 9192fe65db7d8ccbdeed24c89801a27d930ea159 Mon Sep 17 00:00:00 2001 From: Dario Gabriel Lipicar Date: Sun, 9 Aug 2026 17:00:43 -0300 Subject: [PATCH] test(doctests): cover the Qt-typed dependency wrapper, which nothing did The generator emits `_api.{h,cpp}` in two type surfaces -- Qt-typed (QString, the default) and lp (std types, Qt-free) -- and they are separately generated bodies of code. All 7 module definitions across the 5 existing specs declare `"interface": "universal"`, which resolves to ApiStyle::Lp. No job in this repository compiled or executed a single line of the Qt emission, and the consumers that actually use it are real ones: wallet-ui's backend and the tutorial's C++ UI backend. The new spec builds an `interface: universal` notifier and a Qt provider-style watcher (no `interface` key -> LOGOS_API_STYLE defaults to qt), loads both in a logoscore daemon, fires the notifier and asks the watcher what arrived. The watcher subscribes from onInit(), not from a method the spec calls later. That is deliberate: onInit() runs while the dependency's host process has been spawned and has not yet called listen(), which is both the shape every real C++ consumer has and the case a reachability probe answers "no" to. Asserting `subscriptionAccepted` AND `lastGreeted` separates "refused at subscribe time" from "subscribed, module is just quiet" -- the two failures look identical from the callback alone. A zero-count control runs before the emit so the delivered count means something, and the count is re-read after, because a subscription that re-armed and fired twice is as wrong as one that never fired. Co-Authored-By: Claude Opus 5 --- .github/workflows/doctests.yml | 10 +- doctests/cpp-sdk-qt-api-events.test.yaml | 733 +++++++++++++++++++++++ doctests/run.sh | 3 + 3 files changed, 742 insertions(+), 4 deletions(-) create mode 100644 doctests/cpp-sdk-qt-api-events.test.yaml diff --git a/.github/workflows/doctests.yml b/.github/workflows/doctests.yml index 492c11f..4d7cc5c 100644 --- a/.github/workflows/doctests.yml +++ b/.github/workflows/doctests.yml @@ -108,15 +108,17 @@ jobs: # report is complete. The job still fails (non-zero exit) if any step # failed; this only changes whether we stop early. # - # Both specs run back-to-back into one combined report (one dropdown - # entry each): the module-runtime spec and the module-composition spec, - # which builds two modules against this SDK and has one call the other. + # Every spec runs back-to-back into one combined report (one dropdown + # entry each). cpp-sdk-qt-api-events is the only one whose modules are + # NOT `interface: universal`, so it is the only one that compiles and + # runs the Qt-typed dependency-wrapper emission. nix run github:logos-co/logos-doctest -- run \ doctests/cpp-sdk-module-runtime.test.yaml \ doctests/cpp-sdk-module-composition.test.yaml \ doctests/cpp-sdk-worker-thread-http.test.yaml \ doctests/cpp-sdk-concurrent-dispatch.test.yaml \ doctests/cpp-sdk-generator-roundtrip.test.yaml \ + doctests/cpp-sdk-qt-api-events.test.yaml \ --verbose \ --continue-on-fail \ --release-for logos-cpp-sdk=${{ steps.commit.outputs.sha }} \ @@ -144,7 +146,7 @@ jobs: - name: Verify markdown generation run: | - for spec in cpp-sdk-module-runtime cpp-sdk-module-composition cpp-sdk-worker-thread-http; do + for spec in cpp-sdk-module-runtime cpp-sdk-module-composition cpp-sdk-worker-thread-http cpp-sdk-qt-api-events; do nix run github:logos-co/logos-doctest -- generate \ "doctests/$spec.test.yaml" \ --release-for logos-cpp-sdk=${{ steps.commit.outputs.sha }} \ diff --git a/doctests/cpp-sdk-qt-api-events.test.yaml b/doctests/cpp-sdk-qt-api-events.test.yaml new file mode 100644 index 0000000..c24c09e --- /dev/null +++ b/doctests/cpp-sdk-qt-api-events.test.yaml @@ -0,0 +1,733 @@ +name: "A Qt-typed Consumer Subscribing to Another Module's Event" +output: cpp-sdk-qt-api-events.md +release: "" + +intro: | + This SDK's code generator emits a dependency wrapper in **two type + surfaces**, and which one a module gets is decided by its `metadata.json`, + not by anything it writes: + + | the module declares | generated `modules().` signatures | selected by | + |---|---|---| + | `"interface": "universal"` | `std::string`, `std::vector`, … | `LOGOS_API_STYLE=lp` | + | `"interface": "provider"` — the Qt provider style | `QString`, `QVariantList`, … | `LOGOS_API_STYLE=qt`, the default | + + The other doc-tests in this directory all take the first row. This one takes + the second, because the two surfaces are **separately generated code**: an + emission bug in the Qt path cannot be caught by any amount of coverage on the + lp path. + + What it builds: + + 1. `notifier_module`, an ordinary `interface: universal` callee that emits a + `greeted` event. + 2. `qt_watcher_module`, a **Qt provider-style** consumer (`LOGOS_PROVIDER` + + `LOGOS_METHOD`) that declares `notifier_module` as a dependency and + subscribes to `greeted` through the generated Qt-typed + `m_logos->notifier_module.onGreeted(...)`. + 3. Both `.lgx` packages, built against the C++ SDK commit under test, loaded + together in `logoscore`, with the notifier made to fire and the watcher + asked what it received. + + **Where the watcher subscribes is the point.** It subscribes from `onInit()`, + not from a method a test calls later. `onInit()` runs while the host has + spawned `notifier_module`'s process but before that process has called + `listen()` — the dependency is not reachable yet. That is the moment every + real C++ consumer subscribes, and it is the moment a wrapper that asks *"is + this module reachable right now?"* gets the answer *no* and gives up. A + subscription made there and delivered later is the whole assertion. + +what_you_build: "Two modules — an `interface: universal` notifier and a Qt provider-style watcher — built against this SDK commit and run together in `logoscore`, with the watcher receiving an event it subscribed to before the notifier was reachable." + +what_you_learn: + - How `metadata.json` selects between the Qt-typed and Qt-free dependency-wrapper surfaces + - How a Qt provider-style module is written — `LOGOS_PROVIDER`, `LOGOS_METHOD`, and a `PluginInterface` loader + - How to reach a dependency through `LogosModules` with Qt types (`QString`, not `std::string`) + - Why subscribing from `onInit()` is both the realistic shape and the demanding one + - How to load two modules in `logoscore` and prove an event crossed the process boundary + +prerequisites: + - | + **Nix** with flakes enabled. Install from [nixos.org](https://nixos.org/download.html), then enable flakes: + + ```bash + mkdir -p ~/.config/nix + echo 'experimental-features = nix-command flakes' >> ~/.config/nix/nix.conf + ``` + + Verify: `nix flake --help >/dev/null 2>&1 && echo "Flakes enabled"` + - "**git** — nix flakes only see files tracked by git." + - "A Linux or macOS machine." + +sections: + - title: "Create the callee: notifier_module" + step: true + text: | + Nothing about this module is Qt-aware or unusual — it is the same + `interface: universal` shape as every other callee in these doc-tests. It + is here to emit an event on demand. + steps: + - title: "metadata.json" + text: "`interface: universal` selects the pure-C++ pattern: you write one plain class and the builder generates the plugin glue." + file: + path: notifier_module/metadata.json + language: json + content: | + { + "name": "notifier_module", + "version": "1.0.0", + "type": "core", + "category": "general", + "description": "A callee module that emits an event other modules subscribe to", + "main": "notifier_module_plugin", + "interface": "universal", + "dependencies": [], + + "nix": { + "packages": { + "build": [], + "runtime": [] + }, + "external_libraries": [], + "cmake": { + "find_packages": [], + "extra_sources": [] + } + } + } + + - title: "CMakeLists.txt" + text: "For a universal module you list only your plain C++ sources; the generated glue is compiled automatically." + file: + path: notifier_module/CMakeLists.txt + language: cmake + content: | + cmake_minimum_required(VERSION 3.14) + project(NotifierModulePlugin LANGUAGES CXX) + + if(DEFINED ENV{LOGOS_MODULE_BUILDER_ROOT}) + include($ENV{LOGOS_MODULE_BUILDER_ROOT}/cmake/LogosModule.cmake) + elseif(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/cmake/LogosModule.cmake") + include(cmake/LogosModule.cmake) + else() + message(FATAL_ERROR "LogosModule.cmake not found") + endif() + + logos_module( + NAME notifier_module + SOURCES + src/notifier_module_impl.h + src/notifier_module_impl.cpp + ) + + - title: "flake.nix" + file: + path: notifier_module/flake.nix + language: nix + content: | + { + description = "Notifier core module - emits an event for the Qt-api doc-test"; + + inputs = { + logos-module-builder.url = "github:logos-co/logos-module-builder{release}"; + }; + + outputs = inputs@{ logos-module-builder, ... }: + logos-module-builder.lib.mkLogosModule { + src = ./.; + configFile = ./metadata.json; + flakeInputs = inputs; + }; + } + + - title: "src/notifier_module_impl.h — the class" + text: | + The `logos_events:` block declares the event. The token expands to + `public` under a normal compile; the generator reads it and emits the + event body, plus a LIDL contract that consumers turn into a typed + subscriber. + file: + path: notifier_module/src/notifier_module_impl.h + language: cpp + content: | + #pragma once + + #include + #include + + #include // LogosModuleContext base + logos_events + + // A callee module. It exists to emit `greeted` on demand, so a consumer + // can prove its subscription is live. + class NotifierModuleImpl : public LogosModuleContext { + public: + NotifierModuleImpl() = default; + ~NotifierModuleImpl() = default; + + /// Returns a greeting for the given name. + std::string greet(const std::string& name); + + /// Greets the name and also emits a `greeted` event carrying it. + void greetNotify(const std::string& name); + + logos_events: + /// Emitted by greetNotify() with the produced greeting string. + void greeted(const std::string& greeting); + }; + + - title: "src/notifier_module_impl.cpp — the implementation" + text: "Plain C++ — no Qt, no IPC plumbing. `greetNotify` fires the generated event." + file: + path: notifier_module/src/notifier_module_impl.cpp + language: cpp + content: | + #include "notifier_module_impl.h" + + std::string NotifierModuleImpl::greet(const std::string& name) + { + return "Hello, " + name + "!"; + } + + void NotifierModuleImpl::greetNotify(const std::string& name) + { + // Emit the event declared in logos_events:. When loaded by a host + // this reaches every subscriber; constructed outside a host it is a + // safe no-op. + greeted("Hello, " + name + "!"); + } + + - title: "Create the Qt-typed consumer: qt_watcher_module" + step: true + text: | + This is the module the doc-test exists for. Two things make it Qt-typed, + and neither is a flag anyone passes by hand: + + - **`"interface": "provider"`.** Only `universal` asks for the Qt-free + surface, so every other interface leaves `LOGOS_API_STYLE` at its default + of `qt`, which is what `LogosModule.cmake` forwards to the generator. + There is no `"interface": "qt"` to write. + - **It declares `notifier_module` as a dependency.** That is what makes the + builder emit a `notifier_module` accessor on `LogosModules` — with + `QString` in its signatures, and one `on` subscriber per event the + notifier declares. + steps: + - title: "metadata.json — no interface key, one dependency" + text: | + Compare with the notifier's: one word differs, and it decides the whole + type surface. `provider` is the hand-written Qt plugin style + (`LOGOS_PROVIDER` + `LOGOS_METHOD`); `universal` is the pure-C++ one. + Qt typing is not requested — it is what you get by not asking for the + Qt-free surface. + + Leaving `interface` out entirely is **not** the same as writing + `provider`. An absent key means `legacy`, which runs no code generation + at all: the provider dispatch is never emitted and the build fails at + CMake's generate step. + file: + path: qt_watcher_module/metadata.json + language: json + content: | + { + "name": "qt_watcher_module", + "version": "1.0.0", + "type": "core", + "category": "general", + "description": "A Qt-typed consumer: subscribes to notifier_module's event from onInit()", + "main": "qt_watcher_module_plugin", + "interface": "provider", + "dependencies": ["notifier_module"], + + "nix": { + "packages": { + "build": [], + "runtime": [] + }, + "external_libraries": [], + "cmake": { + "find_packages": [], + "extra_sources": [] + } + } + } + + - title: "CMakeLists.txt — PROVIDER_HEADER" + text: | + Only your own sources. The builder generates + `logos_provider_dispatch.cpp` — the table mapping an incoming IPC call + to one of the `LOGOS_METHOD`s — into `generated_code/`, and + `LogosModule.cmake` compiles everything it finds there. + + Two things deliberately absent, both of which break this build: + + - **No `logos_provider_dispatch.cpp` under `SOURCES`.** Named there it + resolves against the source directory, where a generated file does not + exist, and CMake fails at its generate step. + - **No `PROVIDER_HEADER` argument.** It exists for the non-Nix source + layout, where it adds a rule to run the generator. In a Nix build the + generator has already run (`"interface": "provider"` is what schedules + it), so passing it only re-adds the same file a second time and marks + it `GENERATED` — which puts it in front of AUTOMOC and produces a + ninja dependency cycle through the target's own autogen timestamp. + file: + path: qt_watcher_module/CMakeLists.txt + language: cmake + content: | + cmake_minimum_required(VERSION 3.14) + project(QtWatcherModulePlugin LANGUAGES CXX) + + if(DEFINED ENV{LOGOS_MODULE_BUILDER_ROOT}) + include($ENV{LOGOS_MODULE_BUILDER_ROOT}/cmake/LogosModule.cmake) + elseif(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/cmake/LogosModule.cmake") + include(cmake/LogosModule.cmake) + else() + message(FATAL_ERROR "LogosModule.cmake not found") + endif() + + logos_module( + NAME qt_watcher_module + SOURCES + src/qt_watcher_module_loader.h + src/qt_watcher_module_impl.h + src/qt_watcher_module_impl.cpp + ) + + - title: "flake.nix — add the dependency input" + text: | + The input name **must match** the dependency name in `metadata.json`. + The `path:` value is a placeholder — we lock it to the real notifier + checkout in the build step with `--override-input` (Nix will not accept + a relative `../` path written directly into `flake.nix`). + file: + path: qt_watcher_module/flake.nix + language: nix + content: | + { + description = "Qt-typed consumer of notifier_module"; + + inputs = { + logos-module-builder.url = "github:logos-co/logos-module-builder{release}"; + + # The module this one depends on. Placeholder path — locked to the + # real checkout in the build step via --override-input. + notifier_module.url = "path:/path/to/your/notifier_module"; + }; + + outputs = inputs@{ logos-module-builder, notifier_module, ... }: + logos-module-builder.lib.mkLogosModule { + src = ./.; + configFile = ./metadata.json; + flakeInputs = inputs; + }; + } + + - title: "src/qt_watcher_module_impl.h — the class" + text: | + `LOGOS_PROVIDER` declares the module identity; each `LOGOS_METHOD` is + exposed over IPC. Note the types: `QString`, not `std::string` — this + is the Qt surface on both the inbound side (what callers send) and the + outbound one (what the dependency wrapper takes). + file: + path: qt_watcher_module/src/qt_watcher_module_impl.h + language: cpp + content: | + #ifndef QT_WATCHER_MODULE_IMPL_H + #define QT_WATCHER_MODULE_IMPL_H + + #include "logos_provider_object.h" + #include "logos_api.h" + #include "logos_sdk.h" // LogosModules — one Qt-typed accessor per dependency + #include "logos_types.h" + + // A Qt-typed consumer of notifier_module. + // + // The point of this module is WHERE it subscribes: onInit() runs while + // the host has spawned notifier_module's process but before that + // process has called listen(), so the dependency is not reachable yet. + // That is the moment every real C++ consumer subscribes, and the moment + // a wrapper that probes for reachability gives up permanently. + class QtWatcherModuleImpl : public LogosProviderBase + { + LOGOS_PROVIDER(QtWatcherModuleImpl, "qt_watcher_module", "1.0.0") + + protected: + void onInit(LogosAPI* api) override; + + public: + /// A plain sync call through the generated Qt-typed wrapper. + LOGOS_METHOD QString greetThrough(const QString& name); + + /// Whether the subscription made in onInit() was ACCEPTED. Accepted + /// is not the same as armed: it means the wrapper took the + /// subscription rather than refusing it because the module was + /// unreachable at that instant. + LOGOS_METHOD bool subscriptionAccepted(); + + /// The greeting carried by the last `greeted` event, or empty. + LOGOS_METHOD QString lastGreeted(); + + /// How many `greeted` events have arrived. + LOGOS_METHOD int greetedCount(); + + private: + LogosModules* m_logos = nullptr; + bool m_accepted = false; + QString m_lastGreeted; + int m_greetedCount = 0; + }; + + #endif // QT_WATCHER_MODULE_IMPL_H + + - title: "src/qt_watcher_module_loader.h — the plugin entry point" + text: | + A provider-style module supplies its own loader: a small `QObject` that + Qt's plugin system instantiates, and which hands back the + implementation. `Q_PLUGIN_METADATA` embeds `metadata.json` into the + compiled plugin, which is how `lm` and the host read a module's + identity without loading it. + file: + path: qt_watcher_module/src/qt_watcher_module_loader.h + language: cpp + content: | + #ifndef QT_WATCHER_MODULE_LOADER_H + #define QT_WATCHER_MODULE_LOADER_H + + #include + #include "interface.h" + #include "logos_provider_object.h" + #include "qt_watcher_module_impl.h" + + class QtWatcherModuleLoader : public QObject, + public PluginInterface, + public LogosProviderPlugin + { + Q_OBJECT + Q_PLUGIN_METADATA(IID LogosProviderPlugin_iid FILE "metadata.json") + Q_INTERFACES(PluginInterface LogosProviderPlugin) + + public: + QString name() const override { return "qt_watcher_module"; } + QString version() const override { return "1.0.0"; } + LogosProviderObject* createProviderObject() override + { + return new QtWatcherModuleImpl(); + } + }; + + #endif // QT_WATCHER_MODULE_LOADER_H + + - title: "src/qt_watcher_module_impl.cpp — subscribing at init" + text: | + `onGreeted` is generated from the notifier's `logos_events:` block, one + accessor per event, named `on` + the capitalized event name. Its + callback carries the event's parameters already decoded into Qt types — + `const QString&`, not a `QVariantList` the author has to unpack. + + It returns whether the subscription was **accepted**, which is + deliberately not the same question as "is it live now". A module that + has not finished starting is a perfectly good subscription target; it + just has not arrived yet. + file: + path: qt_watcher_module/src/qt_watcher_module_impl.cpp + language: cpp + content: | + #include "qt_watcher_module_impl.h" + + #include + + void QtWatcherModuleImpl::onInit(LogosAPI* api) + { + delete m_logos; + m_logos = new LogosModules(api); + + // Subscribe HERE, at init, not from a method a test calls later. + // notifier_module's host process has been spawned but has almost + // certainly not called listen() yet, so this is the unreachable + // window. The generated accessor takes the subscription now and + // arms it when the module appears. + m_accepted = m_logos->notifier_module.onGreeted( + [this](const QString& greeting) { + m_lastGreeted = greeting; + ++m_greetedCount; + qDebug() << "QtWatcherModuleImpl: greeted ->" << greeting; + }); + + qDebug() << "QtWatcherModuleImpl: subscription accepted =" << m_accepted; + } + + QString QtWatcherModuleImpl::greetThrough(const QString& name) + { + if (!m_logos) return QString(); + return m_logos->notifier_module.greet(name); + } + + bool QtWatcherModuleImpl::subscriptionAccepted() + { + return m_accepted; + } + + QString QtWatcherModuleImpl::lastGreeted() + { + return m_lastGreeted; + } + + int QtWatcherModuleImpl::greetedCount() + { + return m_greetedCount; + } + + - title: "Build both modules against this SDK" + step: true + text: | + Nix flakes only see files tracked by git, so initialise a repo in each + module first. Then build each `.lgx`, overriding `logos-cpp-sdk` to the + commit under test so the generated wrappers, the plugin glue, and the IPC + layer all come from this SDK. + + > Each override URL carries a `{release}` placeholder the doc-test runner + > expands to a concrete ref: locally that is this `logos-cpp-sdk` + > checkout's `HEAD` (see `run.sh`); in CI it is the commit being tested. + > With no pin it falls back to latest `master`. + steps: + - title: "Initialise git repos" + run: | + (cd notifier_module && git init -q && git add -A) + (cd qt_watcher_module && git init -q && git add -A) + check_file: "notifier_module/.git/HEAD" + + - title: "Build the notifier's .lgx against this SDK" + run: | + nix build 'path:./notifier_module#lgx' \ + --override-input logos-module-builder 'github:logos-co/logos-module-builder{release}' \ + --override-input logos-module-builder/logos-cpp-sdk 'github:logos-co/logos-cpp-sdk{release}' \ + --override-input logos-module-builder/logos-qt-sdk/logos-lidl 'github:logos-co/logos-lidl' \ + -o notifier-lgx + code_block: | + nix build 'path:./notifier_module#lgx' \ + --override-input logos-module-builder/logos-cpp-sdk 'github:logos-co/logos-cpp-sdk' \ + --override-input logos-module-builder/logos-qt-sdk/logos-lidl 'github:logos-co/logos-lidl' \ + -o notifier-lgx + post_text: "The notifier package is under `./notifier-lgx/`:" + extra_run: + run: "ls notifier-lgx/*.lgx" + + - title: "Build the watcher's .lgx against this SDK" + text: | + The watcher pulls in `notifier_module` as a dependency, so we lock that + input to the local notifier checkout **and** override `logos-cpp-sdk` + in both builders — so the Qt-typed dependency wrapper the generator + emits, and both plugins, come from one consistent SDK. + run: | + nix build 'path:./qt_watcher_module#lgx' \ + --override-input notifier_module 'path:./notifier_module' \ + --override-input logos-module-builder 'github:logos-co/logos-module-builder{release}' \ + --override-input logos-module-builder/logos-cpp-sdk 'github:logos-co/logos-cpp-sdk{release}' \ + --override-input logos-module-builder/logos-qt-sdk/logos-lidl 'github:logos-co/logos-lidl' \ + --override-input notifier_module/logos-module-builder/logos-cpp-sdk 'github:logos-co/logos-cpp-sdk{release}' \ + --override-input notifier_module/logos-module-builder/logos-qt-sdk/logos-lidl 'github:logos-co/logos-lidl' \ + -o qt-watcher-lgx + code_block: | + nix build 'path:./qt_watcher_module#lgx' \ + --override-input notifier_module 'path:./notifier_module' \ + --override-input logos-module-builder/logos-cpp-sdk 'github:logos-co/logos-cpp-sdk' \ + --override-input logos-module-builder/logos-qt-sdk/logos-lidl 'github:logos-co/logos-lidl' \ + --override-input notifier_module/logos-module-builder/logos-cpp-sdk 'github:logos-co/logos-cpp-sdk' \ + --override-input notifier_module/logos-module-builder/logos-qt-sdk/logos-lidl 'github:logos-co/logos-lidl' \ + -o qt-watcher-lgx + post_text: | + The watcher package is under `./qt-watcher-lgx/`. Reaching this line is + already a result: it means the Qt-typed `notifier_module_api.{h,cpp}` + was generated **and compiled**, which is a different body of emitted + code from the one every other spec in this directory exercises. + extra_run: + run: "ls qt-watcher-lgx/*.lgx" + + - title: "Build the runtime and install both modules" + step: true + text: | + Build `logoscore` (against this SDK) and `lgpm`, then install both modules + into a `./modules` directory the daemon can scan. + steps: + - title: "Build logoscore against this SDK" + run: | + nix build 'github:logos-co/logos-logoscore-cli{release}' \ + --override-input logos-cpp-sdk 'github:logos-co/logos-cpp-sdk{release}' \ + --override-input logos-qt-sdk/logos-lidl 'github:logos-co/logos-lidl' \ + --override-input logos-liblogos/logos-cpp-sdk 'github:logos-co/logos-cpp-sdk{release}' \ + --override-input logos-liblogos/logos-qt-sdk/logos-lidl 'github:logos-co/logos-lidl' \ + --override-input logos-capability-module/logos-module-builder 'github:logos-co/logos-module-builder{release}' \ + --override-input logos-capability-module/logos-module-builder/logos-cpp-sdk 'github:logos-co/logos-cpp-sdk{release}' \ + --override-input logos-capability-module/logos-module-builder/logos-qt-sdk/logos-lidl 'github:logos-co/logos-lidl' \ + --override-input logos-capability-module/logos-module-builder/logos-test-framework/logos-qt-sdk/logos-lidl 'github:logos-co/logos-lidl' \ + --out-link ./logos + code_block: | + nix build 'github:logos-co/logos-logoscore-cli' \ + --override-input logos-cpp-sdk 'github:logos-co/logos-cpp-sdk' \ + --override-input logos-qt-sdk/logos-lidl 'github:logos-co/logos-lidl' \ + --override-input logos-liblogos/logos-cpp-sdk 'github:logos-co/logos-cpp-sdk' \ + --override-input logos-liblogos/logos-qt-sdk/logos-lidl 'github:logos-co/logos-lidl' \ + --override-input logos-capability-module/logos-module-builder/logos-cpp-sdk 'github:logos-co/logos-cpp-sdk' \ + --override-input logos-capability-module/logos-module-builder/logos-qt-sdk/logos-lidl 'github:logos-co/logos-lidl' \ + --override-input logos-capability-module/logos-module-builder/logos-test-framework/logos-qt-sdk/logos-lidl 'github:logos-co/logos-lidl' \ + --out-link ./logos + check_file: "logos/bin/logoscore" + + - title: "Build lgpm" + run: "nix build 'github:logos-co/logos-package-manager#cli' -o lgpm" + check_file: "lgpm/bin/lgpm" + + - title: "Seed the modules directory with the capability module" + text: | + Loading a module goes through the host's capability layer, so the + modules directory needs the `capability_module` that ships with + `logoscore` (rebuilt against this SDK). Copy it across first. + run: | + mkdir -p modules + cp -RL ./logos/modules/. ./modules/ + check_file: "modules/capability_module/manifest.json" + + - title: "Install the notifier" + run: "./lgpm/bin/lgpm --modules-dir ./modules --allow-unsigned install --file notifier-lgx/*.lgx" + expect_contains: + - "Installed to:" + + - title: "Install the watcher" + run: "./lgpm/bin/lgpm --modules-dir ./modules --allow-unsigned install --file qt-watcher-lgx/*.lgx" + expect_contains: + - "Installed to:" + + - title: "Confirm both modules are installed" + run: "./lgpm/bin/lgpm --modules-dir ./modules list" + expect_contains: + - "notifier_module" + - "qt_watcher_module" + check_file: "modules/qt_watcher_module/manifest.json" + + - title: "Load both modules and prove the event arrives" + step: true + text: | + Start `logoscore` in daemon mode (`-D`) so each module's process stays + alive between `call` commands — the subscription registered during the + watcher's `onInit()` has to still be there when a later call makes the + notifier fire. + steps: + - title: "Start the daemon" + run: "sh -c './logos/bin/logoscore -D -m ./modules > logs.txt 2>&1 &'" + code_block: "logoscore -D -m ./modules > logs.txt &" + + - run: "sleep 3" + + - title: "Load the notifier (the dependency first)" + run: "./logos/bin/logoscore load-module notifier_module" + code_block: "logoscore load-module notifier_module" + expect_contains: + - "notifier_module" + + - title: "Load the watcher" + text: | + Loading it runs its `onInit()`, which is where the subscription is + made. Nothing here waits for the notifier to be reachable. + run: "./logos/bin/logoscore load-module qt_watcher_module" + code_block: "logoscore load-module qt_watcher_module" + expect_contains: + - "qt_watcher_module" + + - title: "Confirm both report loaded" + run: "./logos/bin/logoscore status" + code_block: "logoscore status" + expect_contains: + - "notifier_module" + - "qt_watcher_module" + - '"status":"loaded"' + + - title: "The subscription made at init was accepted" + text: | + `true` means the generated wrapper took the subscription instead of + refusing it. A wrapper that probes the dependency for reachability + before subscribing answers `false` here, permanently, because at + `onInit()` time the honest answer to that probe is *no*. + run: "./logos/bin/logoscore call qt_watcher_module subscriptionAccepted" + code_block: "logoscore call qt_watcher_module subscriptionAccepted" + expect_contains: + - '"result":true' + + - title: "A Qt-typed sync call through the same wrapper" + text: | + `greetThrough` reaches `notifier_module.greet` through + `m_logos->notifier_module`, with `QString` on both ends. It confirms + the wrapper is wired up at all, so a silent event later cannot be + mistaken for a dead dependency. + run: "./logos/bin/logoscore call qt_watcher_module greetThrough Sync" + code_block: "logoscore call qt_watcher_module greetThrough Sync" + expect_contains: + - '"result":"Hello, Sync!"' + + - title: "Nothing has been received yet" + text: "The notifier has not fired, so the watcher's state is still empty. This is the control: it makes the next step's result mean something." + run: "./logos/bin/logoscore call qt_watcher_module greetedCount" + code_block: "logoscore call qt_watcher_module greetedCount" + expect_contains: + - '"result":0' + + - title: "Make the notifier fire" + text: "Called directly on the notifier — the watcher is not involved in triggering its own event." + run: "./logos/bin/logoscore call notifier_module greetNotify Qt" + code_block: "logoscore call notifier_module greetNotify Qt" + + - run: "sleep 1" + + - title: "The event crossed the boundary into the Qt-typed subscriber" + text: | + This is the assertion the whole spec exists for: a subscription made + before the dependency was reachable, through the Qt-typed generated + accessor, delivering a decoded `QString` to the callback. + run: "./logos/bin/logoscore call qt_watcher_module lastGreeted" + code_block: "logoscore call qt_watcher_module lastGreeted" + expect_contains: + - '"result":"Hello, Qt!"' + + - title: "Exactly once" + text: "One emit, one delivery — a re-arming subscription that fired twice would be as wrong as one that never fired." + run: "./logos/bin/logoscore call qt_watcher_module greetedCount" + code_block: "logoscore call qt_watcher_module greetedCount" + expect_contains: + - '"result":1' + + - title: "Stop the daemon" + run: "./logos/bin/logoscore stop" + code_block: "logoscore stop" + + - run: "sleep 2" + + - title: "Confirm the daemon has stopped" + run: "./logos/bin/logoscore status || true" + code_block: "logoscore status" + expect_contains: + - '"status":"not_running"' + + - title: "Recap" + text: | + | What was proven | Where | + | --------------- | ----- | + | The Qt-typed wrapper is **emitted and compiles** | the watcher's `.lgx` built at all | + | A subscription made when the dependency is unreachable is **accepted** | `subscriptionAccepted` → `true` | + | The Qt-typed **sync** path works | `greetThrough Sync` → `"Hello, Sync!"` | + | Nothing arrives before the notifier fires | `greetedCount` → `0` | + | The event **arrives, decoded into a `QString`** | `lastGreeted` → `"Hello, Qt!"` | + | It arrives **exactly once** | `greetedCount` → `1` | + + A green run here means something no other spec in this directory can mean. + The Qt-typed dependency wrapper is a **separately generated body of code** + from the `lp` one; the other five specs are all `interface: universal`, so + an emission bug on this path is invisible to every one of them. Here it was + generated, compiled, and executed. + + The `onInit()` placement is what gives the event row teeth. That is the + shape real C++ consumers have — `wallet-ui`'s backend and the tutorial's + C++ UI backend both subscribe from a constructor or context hook, against a + dependency whose host process has been spawned and has not yet called + `listen()`. A wrapper that asked *"is this module reachable?"* before + subscribing would return `false` at that instant and never retry: the + subscription is lost silently, while method calls to the same module keep + working, because calls reach the replica by a path that never asks. Both + the `subscriptionAccepted` row and the `lastGreeted` row are needed to tell + that apart from a module that is simply quiet. diff --git a/doctests/run.sh b/doctests/run.sh index af503d5..3687953 100755 --- a/doctests/run.sh +++ b/doctests/run.sh @@ -7,6 +7,8 @@ # run through logoscore against this SDK # - cpp-sdk-module-composition.test.yaml two modules built against this SDK, # one calling the other over IPC +# - cpp-sdk-qt-api-events.test.yaml a Qt-typed consumer subscribing to a +# dependency's event from onInit() # # The runner is the shared `doctest` CLI # (https://github.com/logos-co/logos-doctest), invoked directly via its flake. @@ -31,6 +33,7 @@ SPECS=( "cpp-sdk-worker-thread-http.test.yaml" "cpp-sdk-concurrent-dispatch.test.yaml" "cpp-sdk-generator-roundtrip.test.yaml" + "cpp-sdk-qt-api-events.test.yaml" ) # Build the doc-tests against THIS repo's current commit rather than the latest