Desktop lifecycle harnesses
The headless lifecycle harness uses a real QgsApplication and the adapter:
pixi run -e default pytest -c packages/qgis-sdk-python/pyproject.toml packages/qgis-sdk-python/tests/scripts/test_qualification_lifecycle.py -m smoke -sExample evidence from an earlier run:
{ "action_names": ["ping"], "action_result": 7, "error": null, "owned_resources_released": true, "passed": true, "plugin_started": true, "plugin_unloaded": true, "qgis_exited": true, "qgis_initialised": true, "qgis_version": "4.2.3-Belém do Pará", "qt_version": "6.11.2"}The harness passes iface=None, starts no event loop and does not exercise
menus, dialogs or docks, so it is a headless fallback and not a real
Desktop startup/unload qualification. Run the opt-in test with:
pixi run -e default pytest -c packages/qgis-sdk-python/pyproject.toml packages/qgis-sdk-python/tests/scripts/test_qualification_lifecycle.py -m smoke -sReal Desktop startup and unload evidence
Section titled “Real Desktop startup and unload evidence”The Desktop adapter smoke test supplies the evidence that harness cannot: it writes
a small fixture plugin whose __init__.py is the one line qgis-sdk build stages
(classFactory = host("<pkg>.plugin:<Class>"), decision-43) with its own
context_factory, installs it into a clean throwaway profile, and lets the QGIS
Desktop application’s own plugin manager load, start and unload it. The fixture
belongs to this harness rather than to the scaffolded project, so the host contract
is proven without depending on the scaffold.
pixi run -e default pytest -c packages/qgis-sdk-python/pyproject.toml packages/qgis-sdk-python/tests/scripts/test_qualification_desktop_adapter.py -m smoke -sExample evidence from an earlier run:
{ "action_result": 7, "actions_detached": true, "error": null, "form_submitted": true, "iface_is_host": true, "message_bar_received": true, "native_context_matches": true, "passed": true, "runtime_running": true, "runtime_stopped": true, "scope_released": true, "started": true, "unloaded": true}“No fake fallback” is what makes each field worth reading: the run fails outright if
QGIS provides no interface object, iface_is_host requires the adapter to hold the very
same object as qgis.utils.iface, native_context_matches requires
ctx.desktop to hand that live object back through the typed escape hatch, and
message_bar_received requires the notifications service to add a real widget to QGIS’s
own bar, and form_submitted requires the fixture’s Form.for_action dialog, opened
through ctx.ui, to reach the ping handler when its buttons/run button is clicked.
scope_released and actions_detached are the runtime’s side of the same
cycle: the owned scope closed and no stale callable survived unload().
QGIS’s module cache is deliberately not asserted. unloadPlugin drops only the modules
_plugin_modules happened to record, which is QGIS bookkeeping rather than a promise of
this SDK, so a check there would prove the wrong thing.
The headless-Desktop rules that are easy to get wrong - QT_QPA_PLATFORM=offscreen, the
placeholder DISPLAY QGIS needs to consider itself interactive, and PYTHONHOME at the
live prefix so the embedded interpreter can import sys - live in
tests/smoke_support/desktop_smoke_kit.py, shared with the scaffold harness. Offscreen Qt shows no
window and renders nothing: GUI, canvas, 3D and WebEngine behaviour remain under
TASK-33, as does every non-Linux candidate.
pixi run -e default pytest -c packages/qgis-sdk-python/pyproject.toml packages/qgis-sdk-python/tests/scripts/test_qualification_desktop_adapter.py -m smoke -sThe Qt scheduler harness exercises the StepScheduler seam inside a real
QgsApplication on the offscreen platform: alternating GUI phases and worker
handoffs, cancellation acknowledgement, refusal of late reports after disposal,
and quiesce blocking on an unresolved handoff. It compares tick latency while a
worker is blocked against latency while phases are scheduled:
pixi run -e default pytest -c packages/qgis-sdk-python/pyproject.toml packages/qgis-sdk-python/tests/scripts/test_qualification_scheduler.py -m smoke -sExample evidence from an earlier run:
{ "blocking_phase_max_ms": 61.412, "passed": true, "scheduled_phase_max_ms": 2.593, "scope_review_criteria": [], "verdict": "adopt-scheduler-seam"}The numbers are one Linux/offscreen sample, not a latency guarantee. Run the opt-in test with:
pixi run -e default pytest -c packages/qgis-sdk-python/pyproject.toml packages/qgis-sdk-python/tests/scripts/test_qualification_scheduler.py -m smoke -sThe network limits probe measures what
decision-4
left to TASK-5: how QgsNetworkAccessManager handles redirects on its own
(policy, hop count, which headers it forwards to another origin), what a
transport-followed hop costs, and how long the GUI thread stalls when a 1 to
128 MiB body is handed over in one piece. It prints one JSON report with a
recommended block:
pixi run -e default python packages/qgis-sdk-python/scripts/network_limits_probe.pyThe shipped defaults are 20 redirects and a 16 MiB buffered body. The probe
also found that QgsNetworkAccessManager forwards Authorization and
Cookie to a different origin when it follows a redirect itself, which is
why the transport follows redirects on its own and never delegates them to Qt.
tests/scripts/test_network_limits_probe.py pins the recommendation rule in the
QGIS-free suite; the measurement itself is opt-in:
QGIS_SDK_NETWORK_PROBE=1 pixi run -e default pytest -c packages/qgis-sdk-python/pyproject.toml packages/qgis-sdk-python/tests/scripts/test_network_limits_probe.pyThe transport’s own integration coverage is tests/test_network_native.py:
loopback http.server origins only, run offscreen by the CI step “Test native
forms and networking offscreen” and by the coverage run in the default
environment. It covers the coroutine and blocking forms, timeout versus
cancellation, scope close aborting replies, the body ceiling aborting a
download, cross-origin redirects dropping Authorization/Cookie/auth
configuration, https → http refusal, the QGIS proxy setting (proxy/proxy-*
settings entries routed to a loopback proxy), a Basic authentication
configuration applied by QgsAuthManager from a throwaway auth database
(QGIS_AUTH_DB_DIR_PATH), an untrusted self-signed certificate as
QgisSdkTlsError, and the same certificate accepted once stored as a QGIS
certificate authority.
Optional native-runtime plugin coexistence (TASK-33 AC #3)
Section titled “Optional native-runtime plugin coexistence (TASK-33 AC #3)”Historical evidence only: the Pydantic-specific SDK adapter and its coexistence harness have been removed. The following report records a past test of 0.7.0-era code and does not qualify a current SDK adapter or general third-party package compatibility in QGIS.
{ "action_result": 7, "both_loaded": true, "error": null, "neighbor_plugin": "neighbor_pydantic_probe", "neighbor_started": true, "neighbor_survives_sdk_unload": true, "neighbor_unloaded_cleanly": true, "neighbor_validates_while_coexisting": true, "passed": true, "platform": "Linux-6.1.158+-x86_64-with-glibc2.36", "pydantic_core_version": "2.50.1", "pydantic_preloaded": true, "pydantic_version": "2.14.1", "python_version": "3.14.8", "qgis_version": "4.2.3-Belém do Pará", "sdk_contract_dispatched": true, "sdk_plugin": "sdk_pydantic_probe", "sdk_started": true, "sdk_unloaded_cleanly": true, "shared_native_runtime": true}The retired harness installed both plugins into a clean throwaway profile, loaded and started the
neighbor plugin first so pydantic and pydantic_core._pydantic_core were already in
sys.modules, loaded and started the SDK plugin alongside it, confirmed both shared the
identical native module objects, exercised strict request validation and rejection in
PydanticContractAdapter while the neighbor also validated payloads, and verified that
unloading the SDK plugin closed its ResourceScope while leaving the neighbor plugin
loaded and functional.
Load and unload evidence for the scaffolded project
Section titled “Load and unload evidence for the scaffolded project”A standalone run cannot prove QGIS accepts the package, so
the scaffold smoke test creates a project into a temporary directory, installs
the plugin package into a clean throwaway profile, and runs the QGIS Desktop
binary so that QGIS’s own plugin machinery - qgis.utils.findPlugins,
loadPlugin, startPlugin, unloadPlugin - discovers it, parses its
metadata.txt (rendered by build from pyproject.toml) with QGIS’s own parser,
calls the staged classFactory(iface) - host() - and initGui(),
invokes the declared action and unloads it, with the SDK reporting running and
then stopped:
pixi run -e default pytest -c packages/qgis-sdk-python/pyproject.toml packages/qgis-sdk-python/tests/scripts/test_qualification_scaffold.py -m smoke -sHeadless Desktop needs three variables that are easy to get wrong, which is why the
harness sets them instead of leaving them to a recipe: QT_QPA_PLATFORM=offscreen
(no window system, nothing rendered), a placeholder DISPLAY (QGIS decides it is
non-interactive from DISPLAY alone and refuses to start), and PYTHONHOME at the
live prefix (QGIS carries its build-time prefix in the embedded interpreter, so a
relocated install starts and then cannot even import sys). This qualifies the
Desktop plugin path, not GUI behaviour: no widget is shown, so canvas, map and 3D
rendering still need a real display. Last run here: QGIS 4.2.3 and CPython 3.14.8
on Linux, "passed": true. The same kit backs the adapter evidence in
Real Desktop startup and unload evidence.
Generated web projects: toolchain and in-QGIS evidence
Section titled “Generated web projects: toolchain and in-QGIS evidence”qgis-sdk new --web vanilla|react writes a Vite app whose dependencies are
pinned to the versions the release was qualified with (WEB_TEMPLATE_VERSIONS
in qgis_sdk_tools/scaffold/web.py, @archont561/qgis-sdk by exact version). The
unit tests prove the plan - file list, pins, bridge generate --check finding
no drift in the checked-in client, check passing with no native exemption,
the starter pytest passing - without a network. What they cannot prove is that
the pinned toolchain resolves and that the built page works inside Chromium,
so tests/test_scaffold_web_native.py is opt-in. It runs the generated page on
the workspace bridge, not the pinned release: the session fixture
workspace_bridge (conftest.py, layers.pack_workspace_bridge) builds
packages/qgis-sdk-typescript and packs it with bun pm pack, and the
scaffold receives that tarball through sdk_dependency= - the same
--sdk-dependency flag the CLI exposes (decision-41). A tarball rather than
the directory because Bun would try to install the directory’s own
workspace:* tooling, and because a tarball is exactly what a release ships.
QGIS_SDK_SCAFFOLD_WEB=1 QT_QPA_PLATFORM=offscreen pixi run -e default pytest -c packages/qgis-sdk-python/pyproject.toml packages/qgis-sdk-python/tests/test_scaffold_web_native.pyFor every package manager it finds (bun, npm, pnpm on PATH, or Bun in
the sibling Pixi environment) and for both frameworks it scaffolds, runs the
manager’s install, test (Vitest against the scripted host), typecheck
(tsc) and build, then loads the built <plugin>/web/index.html in a
restricted QWebEnginePage under a headless QgsApplication, clicks Ping in
the page and waits for the plugin’s answer to arrive over QWebChannel; the
React project opens as a dock, the vanilla one as a dialog. Last run here:
2026-10-25, QGIS 4.2.3 (Qt 6.11.2, Chromium 140) on Linux with Bun 1.4.2,
npm 10.9.8 (Node 22) and pnpm 10.34.8: 12 passed. A manager that is absent
is a skip, never a pass.
qgis-sdk test and qgis-sdk dev against real QGIS (TASK-26, TASK-6)
Section titled “qgis-sdk test and qgis-sdk dev against real QGIS (TASK-26, TASK-6)”The unit tests of qgis_sdk_tools.devtools assert on plans - the argv, working
directory, environment and notes a command would run with - through
FakeCliRuntime, and compile the startup scripts the commands hand to QGIS
(a --code script with a syntax error makes a headless QGIS hang in a modal
error dialog, silently). What only the binary can prove is opt-in:
QGIS_SDK_DEVTOOLS=1 QT_QPA_PLATFORM=offscreen pixi run -e default pytest -c packages/qgis-sdk-python/pyproject.toml packages/qgis-sdk-python/tests/test_devtools_native.pyIt scaffolds a project, adds one qgis-marked and one desktop-marked test,
and drives the command line a user would type: test (pure) deselects both;
test --target qgis runs the first under a headless QgsApplication;
test --target desktop runs the second inside QGIS Desktop with the real
iface, loading the source plugin through qgis.utils; a failing desktop
test fails the command with pytest’s exit code 1; a native target in which
every test skipped fails too. dev --exercise <report> (a hidden flag that
hands the bootstrap to qgis_sdk_tools.devtools.exercise) proves the
Desktop story and the TASK-21 reload policy end to end in one QGIS process:
the source plugin starts from QGIS_PLUGINPATH, reloads, a file with a
syntax error is refused while revision 2 keeps running, after the file is
removed and the plugin’s ping is edited to add 1000 the third revision
answers 1001; a job whose worker thread blocks on an event makes the next
reload refuse after the 2 s bound with the blocker job exercise.stuck#1 is waiting on an unresolved worker handoff while the plugin and the job stay
running, and the reload after the event is released succeeds; an import
error injected into plugin.py fails the start after teardown, the plugin
is reported disabled and not active, and the next reload recovers; three
back-to-back requests become one reload; with the watcher running, an edit
to plugin.py (+ 2000) reloads once without a button press and ping
answers 2001. Eight revisions in total. The reload policy itself is unit
tested without QGIS in tests/test_devtools_reloader.py against a scripted
plugin host, and the runtime side (scope.on_quiesce,
PluginRuntime.quiesce) in tests/test_quiesce.py.
dev --mode standalone reports the plugin running
with its window and a ping answer. With Bun available (on PATH or in the
sibling Pixi environment) it also scaffolds a vanilla web project, installs
its dependencies, and runs dev with the Vite server the command starts -
the report carries view_shown - then, in-process, loads the Vite-served
page in a restricted QWebEnginePage, clicks Ping, edits web/src/app.ts,
waits for the page to show the edit without a command restart and clicks
Ping again. Last run here: 2026-10-25, QGIS 4.2.3 (Qt 6.11.2, Chromium 140)
on Linux with Bun 1.4.2 and Vite 8.3.5: 9 passed in 19 s.
Built ZIP in a clean profile (TASK-8 AC4, decision-3), opt-in:
QGIS_SDK_BUILD_INSTALL=1 QT_QPA_PLATFORM=offscreen pixi run -e default pytest -c packages/qgis-sdk-python/pyproject.toml packages/qgis-sdk-python/tests/test_bundle_native.pytests/test_bundle_native.py builds two scaffolded plugins with
build_plugin, unpacks both archives into a fresh profile’s
python/plugins/ (what Install from ZIP does), marks each vendored
qgis_sdk copy so they stand in for two SDK versions, and starts QGIS
Desktop with PYTHONPATH stripped of the SDK and a --code probe that
inserts a sys.meta_path finder refusing every qgis_sdk import. Both
plugins are listed, load and start through qgis.utils, answer ping, their
runtime classes come from <plugin>._vendor.qgis_sdk.* and are distinct
objects, each sees its own mark, qgis_sdk never enters sys.modules, and
both unload. The archive itself - layout, exclusions, vendoring and import
rewrite, reproducibility, validation rules, the web build step - is covered
without QGIS in tests/test_bundle.py (which also imports the extracted
archive in-process) and tests/test_bundle_cli.py. Last run here:
2026-10-25, QGIS 4.2.3: 1 passed in 4 s.
Reference plugin through the toolchain (TASK-22)
Section titled “Reference plugin through the toolchain (TASK-22)”examples/simple (plugin id simple) is the decision-19 workflow as one plugin: open
the bundled data/points.geojson, read the selection, buffer through
Processing in an explicit projected CRS into a new layer styled with the
packaged reference/polygon-output QML before it is presented, and export
that layer to a bare file name inside a directory a native folder picker
chose. Its five actions sit behind both the native BufferDialog built from
Form.for_action and the vanilla page in web/, through QgisSdk on the generated
web/src/bridge.schema.ts. The project was written by qgis-sdk new and keeps that
layout, so the example doubles as the proof that the commands compose.
The workflow class (simple/workflow.py) is the plugin’s own code:
the runtime ships the services and the reference algorithms, and what a
plugin chains them into stays out of qgis_sdk (so the example is a worked
example, not a hidden runtime feature). Deterministic, no QGIS: the export
policy (tests/test_exports.py) and the fake/native layer contract
(tests/services/test_layers_contract.py, nine export cases per backend) in
the regular suite; the plugin’s seventeen pure tests in its own qgis-sdk test (declared actions, fixture open, selection, styled buffer with the
source untouched, refusals for a missing CRS, a missing export directory and
a file name that is a path, and the ordering file: styled before presented,
nothing presented on cancellation, an incompatible style or a host without
Processing, the plugin’s own scope). CI runs the example’s qgis-sdk test
and qgis-sdk test --target qgis on Linux through the test-reference-example
Pixi task, and the page’s Vitest suite on the workspace bridge through
test-reference-web (examples/*/web are Bun workspace members; turbo builds
@archont561/qgis-sdk before simple-web’s test). The full native half is
opt-in:
QGIS_SDK_REFERENCE=1 QT_QPA_PLATFORM=offscreen pixi run -e default pytest -c packages/qgis-sdk-python/pyproject.toml packages/qgis-sdk-python/tests/test_reference_example_native.pytests/test_reference_example_native.py runs qgis-sdk new with the
example’s options into a temporary directory (--sdk-dependency pointing at
the packed workspace bridge, see above), copies the example over it except
web/package.json - the example’s says workspace:*, which resolves only
inside the repository’s Bun workspace, and the pure
test_reference_example.py proves it is otherwise byte-for-byte the
scaffold’s - then drives the command line: check (no problems), bridge generate --check (no drift), test (17 passed on the fakes), test --target qgis
(4 passed: the end-to-end run with the fixture byte-identical afterwards, a
geographic working CRS refused with nothing added, the native dialog and a
direct call dispatching one handler, the workflow class refusing an unknown
layer and a silent overwrite), Bun install/test/typecheck/
build, qgis-sdk build (the ZIP carries the fixture, the built page, the
vendored runtime and its polygon style, no tests), and dev --exercise
inside QGIS Desktop with the Vite server (a project without a ping action
gets the smoke report: started, reloaded, view shown, unloaded). Finally, in
a headless QgsApplication, it opens the plugin’s dock with the built page
in a real QWebEngineView, clicks Open, Buffer and Export, reads the page’s
answers over QWebChannel, then renders the native buffer dialog for the
same source layer and dispatches it: both outputs are polygon layers in
EPSG:3857 with the source’s three features and a SimpleFill renderer from
the packaged style, the project holds exactly source + two outputs, the
fixture’s sha256 is unchanged and the export directory holds exactly the
one file the page named. Run it in its own process: like the scaffold-web
harness it drives a generated plugin’s view on Chromium’s default profile
(tests/conftest.py skips the other webengine modules when either is
collected).
Evidence rows (clean profile, task-22 AC 3). Recorded as run, not rounded up: the Linux row is in-sandbox evidence; no Windows host exists in this workspace and the cross-platform job (doc-8) has not run, so the Windows row is a blocker carried by TASK-33, not a pass.
| Target | QGIS | Python | Qt | Chromium | GDAL | Bun / Vite | Harness | Outcome |
|---|---|---|---|---|---|---|---|---|
| Linux x86-64 (glibc 2.36, offscreen) | 4.2.3 | 3.14.8 | 6.11.2 | 140.0.7339.225 | 3.13.3 | 1.4.2 / 8.3.5 | QGIS_SDK_REFERENCE=1, 2026-10-25 |
5 passed in 12 s; services 133 passed with QGIS_SDK_NATIVE_SERVICES=1 (after the workflow class moved into the example; its cases run in the example’s own qgis-sdk test) |
| Windows x86-64 | - | - | - | - | - | - | none available | blocked: no host, no CI runner (TASK-33) |
The bridge inside the real QWebEngineView is the published
@archont561/qgis-sdk 0.3.0 from npm (the example pins it like any generated
project); no bridge defect surfaced, so the bridge is not re-released for
0.5.0.