Skip to content

Desktop lifecycle harnesses

The headless lifecycle harness uses a real QgsApplication and the adapter:

Terminal window
pixi run -e default pytest -c packages/qgis-sdk-python/pyproject.toml packages/qgis-sdk-python/tests/scripts/test_qualification_lifecycle.py -m smoke -s

Example 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:

Terminal window
pixi run -e default pytest -c packages/qgis-sdk-python/pyproject.toml packages/qgis-sdk-python/tests/scripts/test_qualification_lifecycle.py -m smoke -s

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.

Terminal window
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 -s

Example 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.

Terminal window
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 -s

The 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:

Terminal window
pixi run -e default pytest -c packages/qgis-sdk-python/pyproject.toml packages/qgis-sdk-python/tests/scripts/test_qualification_scheduler.py -m smoke -s

Example 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:

Terminal window
pixi run -e default pytest -c packages/qgis-sdk-python/pyproject.toml packages/qgis-sdk-python/tests/scripts/test_qualification_scheduler.py -m smoke -s

The 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:

Terminal window
pixi run -e default python packages/qgis-sdk-python/scripts/network_limits_probe.py

The 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:

Terminal window
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.py

The 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:

Terminal window
pixi run -e default pytest -c packages/qgis-sdk-python/pyproject.toml packages/qgis-sdk-python/tests/scripts/test_qualification_scaffold.py -m smoke -s

Headless 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.

Terminal window
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.py

For 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:

Terminal window
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.py

It 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:

Terminal window
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.py

tests/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:

Terminal window
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.py

tests/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.