Files
php-qml/examples/todo
magdev de4a14da36 v0.2.0 (13/N): qmltestrunner harness + CI wiring + close out v0.2.0 plan
Closes the testing-strategy row of PLAN.md §13 v0.2.0 and parks the
two remaining items with rationales.

Shipped:

- framework/qml/tests/{CMakeLists.txt, main.cpp, tst_smoke.qml}
  Qt Quick Test scaffold: QUICK_TEST_MAIN bootstrap + one smoke test
  proving the harness loads. New tests land as tst_<feature>.qml in
  the same dir; qmltestrunner auto-discovers them. Built only when
  -DBUILD_TESTING=ON (production AppImages stay clean).
- skeleton + example/todo Makefiles: `make qmltest` target invokes
  the configure → build → ctest dance. `make quality` now depends
  on qmltest.
- .gitea/workflows/ci.yml: `QML unit tests` step after qmllint in
  the Quality job. Out-of-tree build dir (build-tests) so the
  CTest run doesn't pollute the cached release build.

Verified locally: configure + build + ctest pass, both smoke
assertions pass, runs in 0.5s.

Closed in PLAN.md §13 v0.2.0 with rationale (no code change):

- Build-time Symfony cache warmup → moved to v0.3.0. The obvious
  approach (cache:warmup at build, copy at first launch) doesn't
  save any time because Symfony bakes absolute kernel.project_dir
  into the compiled cache, and the AppImage's FUSE mount path
  changes every launch — every cached path is stale on launch N+1.
  Doing it properly requires virtualising getProjectDir(), symlink
  fix-up, multi-app namespacing — its own minor's worth of design.
- ReactiveObject cursor pagination → closed N/A. ReactiveObject
  already has pending / invoke() / Idempotency-Key correlation /
  version-gap detection at parity with ReactiveListModel; the only
  feature it lacks is *pagination*, which is meaningless for a
  single-entity model.

That fully closes the v0.2.0 plan as documented. Remaining v0.2.0
items in PLAN.md §13 are the audit-ends already shipped earlier in
the cycle (interfaces / BridgeOp / BridgeBundleInfo / Maker DRY /
--with-dto / port negotiation / pre-migration backup / bridge:export
/ periodic auto-update / native-dialogs doc / event maker /
read-model maker / qmltestrunner) plus the two parked items
documented above. Ready to tag when the user gives the word.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 21:02:30 +02:00
..

examples/todo

The framework's POC application: a real Qt/QML todo app whose backend is a Symfony service generated entirely by the make:bridge:* makers. Demonstrates every architectural primitive in PLAN.md §13 Phase 3.

What's here that wasn't in the skeleton

  • Todo resource (entity + controller + QML snippet) generated via make:bridge:resource Todo.
  • MarkAllDone command generated via make:bridge:command MarkAllDone, with a body that flips done = true on every todo.
  • TodoWindow.qml second-window scaffold generated via make:bridge:window Todo, customised to embed a read-only mirror of the same ReactiveListModel.
  • Main.qml rewritten as a real list UI with add input, per-row toggle/delete, "mark all done", and "open second window".

Everything cross-side (PHP ↔ QML) is maker-generated. There is no handwritten bridge glue in this example.

Run it

make install        # composer install
make build          # cmake + qt host
make doctor         # bridge:doctor — readiness check
make dev            # FrankenPHP --watch + Qt host

Add a few todos, toggle them, click "Mark all done", click "Open second window" — both windows stay in sync.

Multi-window test

  1. make dev starts the app.
  2. Add three todos via the input field.
  3. Click Open second window. A Todos (mirror) window opens.
  4. In the main window, toggle one of the todos. The mirror flips its row within ~50 ms (Mercure SSE).
  5. Add a new todo in the main window. It appears in the mirror within ~50 ms.
  6. Click Mark all done in the main window. Both windows show all rows ticked simultaneously.

Each window has its own ReactiveListModel instance subscribed to the same app://model/todo topic; the framework keeps them coherent without per-window glue.

Crash-and-recover test

  1. make dev is running.
  2. Add a todo so the list is non-empty.
  3. From another terminal: pkill -f 'examples/todo/symfony.*frankenphp'.
  4. The app's AppShell shows the Reconnecting banner (visible on the second window; the main window keeps its own status display).
  5. Restart the FrankenPHP child: from the example dir, cd symfony && frankenphp run --watch --config ../Caddyfile — or just re-run make dev.
  6. The app flips back to Online and re-fetches the list. No row corruption.

Layout

todo/
  Caddyfile           # FrankenPHP / Mercure config (port 8765, anonymous SSE)
  Makefile            # build / dev / doctor / quality
  scripts/dev.sh
  symfony/
    composer.json     # path repo points one level deeper at framework/php
    config/
    src/Entity/Todo.php                        # generated by make:bridge:resource
    src/Controller/TodoController.php          # generated, CRUD on /api/todos
    src/Controller/MarkAllDoneController.php   # generated, body filled in
    public/index.php
    bin/console
    .env
    migrations/
  qml/
    CMakeLists.txt
    main.cpp
    Main.qml          # real todo UI (add / toggle / delete / mark-all / 2nd-window)
    TodoList.qml      # generated; also reused by the second window
    TodoWindow.qml    # generated, customised to embed mirror list

What this example proves

  • The headline workflow scales: a non-trivial app's PHP/QML wiring is all generated.
  • ReactiveListModel handles in-flight optimistic mutations (pending role drops opacity on toggled rows until the Mercure echo lands).
  • Two windows of the same QML app stay coherent under mutations from either side.
  • Stopping FrankenPHP mid-session triggers ReconnectingOffline UI; restart restores Online and re-fetches without dupes.
  • Idempotency-Key round-trips through to Mercure as correlationKey — visible in dev.log if you grep correlationKey.

Out of scope here

  • A CI-driven version of the multi-window / crash-recover tests lives in Phase 3 sub-commit 4 as a bridge-integration suite.
  • No persistence options (export, backup) — same SQLite var/data.sqlite as the skeleton. Apps move to Postgres by overriding DATABASE_URL.

AppImage packaging (Phase 4a)

FRANKENPHP=/path/to/frankenphp make appimage
./build/Todo-x86_64.AppImage

make appimage stages a composer install --no-dev copy of the Symfony app, downloads linuxdeploy + appimagetool + the AppImageUpdate sidecar (cached under packaging/linux/tools/, gitignored), and produces build/Todo-x86_64.AppImage (~150 MB). The AppImage carries everything needed: Qt runtime, the bundled FrankenPHP binary, the Symfony app, and an AppImageUpdate.AppImage sidecar for in-place updates.

When the AppImage runs without BRIDGE_URL set, BackendConnection switches to bundled mode: it spawns the embedded FrankenPHP, generates a per-session bearer token, runs first-launch migrations into ~/.local/share/php-qml/todo/var/data.sqlite, and supervises the child. Killing FrankenPHP from outside the AppImage triggers an automatic restart with a fresh token (PLAN.md §3 Edge cases — Per-session secret rotation).

Auto-update

The AppImage is built with an embedded update-info ELF section pointing at the canonical Gitea Releases URL for its tag (set via APPIMAGE_UPDATE_INFO at build time). The bundled sidecar implements the actual download and patch via zsync.

From QML:

Connections {
    target: BackendConnection
    function onUpdatesAvailable() { ... }
    function onNoUpdatesAvailable() { ... }
    function onUpdateApplied() { ... } // restart prompt
}

Button { text: "Check for updates"; onClicked: BackendConnection.checkForUpdates() }
Button { text: "Update"; onClicked: BackendConnection.applyUpdate() }

Performance smoke

make perf

Asserts the §11 Performance budgets: bundle ≤ 200 MB, cold start ≤ 2 s (4 s on shared CI runners), idle RSS ≤ 200 MB. CI runs this on every release tag before publishing.