test(doctests): cover the Qt-typed dependency wrapper, which nothing did

The generator emits `<dep>_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 <noreply@anthropic.com>
This commit is contained in:
Dario Gabriel Lipicar
2026-08-09 17:53:57 -03:00
co-authored by Claude Opus 5
parent dfd46282a7
commit 9192fe65db
3 changed files with 742 additions and 4 deletions
+6 -4
View File
@@ -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 }} \
+733
View File
@@ -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().<dep>` signatures | selected by |
|---|---|---|
| `"interface": "universal"` | `std::string`, `std::vector<uint8_t>`, … | `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 <cstdint>
#include <string>
#include <logos_module_context.h> // 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<Event>` 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 <QObject>
#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 <QDebug>
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.
+3
View File
@@ -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