Example Project
examples/demo-addon/ in this repository is a complete, minimal add-on that this kit tests
itself against. It’s deliberately boring: one gesture, one spoken phrase, one log line at
startup — enough surface to assert against, nothing more.
The add-on
name = testkit-demo
summary = "Testkit Demo Add-on"
description = """A trivial add-on that exists only so nvda-addon-testkit can test itself."""
author = "Zirek <https://github.com/zirekhq>"
url = https://github.com/zirekhq/nvda-addon-testkit
version = 1.0.0
minimumNVDAVersion = 2024.1
# Kept ahead of any NVDA release for the same reason as spy/manifest.ini: this
# add-on only ever runs inside disposable instances this kit provisions,
# including alpha snapshots whose compat floor isn't known ahead of time.
lastTestedNVDAVersion = 2099.1.0
"""A deliberately boring add-on: it speaks a known phrase on a known gesture,
and writes a known line to the log at startup. That is enough surface for the
kit's own end-to-end tests to assert against.
"""
from typing import ClassVar
import globalPluginHandler
import ui
from core import postNvdaStartup
from logHandler import log
STARTUP_MESSAGE = "testkit demo add-on loaded"
SPOKEN_PHRASE = "testkit demo says hello"
class GlobalPlugin(globalPluginHandler.GlobalPlugin):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
postNvdaStartup.register(self._logStartup)
def _logStartup(self):
log.info(STARTUP_MESSAGE)
def script_sayHello(self, gesture):
ui.message(SPOKEN_PHRASE)
# Read by NVDA's ScriptableObject via mangled-name getattr, not a direct reference.
__gestures: ClassVar[dict[str, str]] = {"kb:NVDA+shift+control+d": "sayHello"} # NOSONAR(S4487)
Build the bundle
#!/usr/bin/env python3
"""Zip the demo add-on into examples/demo-addon.nvda-addon."""
from __future__ import annotations
import zipfile
from pathlib import Path
SOURCE = Path(__file__).resolve().parent
BUNDLE = SOURCE.parent / "demo-addon.nvda-addon"
def build() -> Path:
files = sorted(
path
for path in SOURCE.rglob("*")
if path.is_file()
and path.name != "build.py"
and "__pycache__" not in path.parts
and path.suffix != ".pyc"
)
with zipfile.ZipFile(BUNDLE, "w", compression=zipfile.ZIP_DEFLATED) as archive:
for path in files:
archive.write(path, path.relative_to(SOURCE).as_posix())
print(f"wrote {BUNDLE}")
return BUNDLE
if __name__ == "__main__":
build()
Use whatever your own add-on already builds with instead — scons dist, a Makefile target, this
same zip-the-folder script. The testkit only cares that a .nvda-addon file lands somewhere the
addon-bundle pattern can glob.
Test it
Most tests just ask for nvda and addon_under_test:
def test_it_survives_a_restart(nvda, addon_under_test):
nvda.restart()
assert nvda.addons.state("testkit-demo") is AddonState.ENABLED
before = nvda.speech.index()
nvda.keys.press("NVDA+shift+control+d")
nvda.speech.wait_for(SPOKEN_PHRASE, timeout=20, since=before)
A test that owns the install lifecycle itself asks for nvda.addons directly instead — see
nvda.addons for what each state means:
@pytest.mark.fresh_nvda
def test_install_is_two_phase_and_completes_on_restart(
nvda, built_demo_addon, assert_no_unexpected_errors
):
assert nvda.addons.state("testkit-demo") is AddonState.NOT_INSTALLED
info = nvda.addons.install(built_demo_addon)
assert info.name == "testkit-demo"
assert nvda.addons.state("testkit-demo") is AddonState.PENDING_INSTALL
nvda.restart()
assert nvda.addons.state("testkit-demo") is AddonState.ENABLED
assert_no_unexpected_errors(nvda)
nvda.addons.remove("testkit-demo")
nvda.restart()
assert nvda.addons.state("testkit-demo") is AddonState.NOT_INSTALLED
Run it
python examples/demo-addon/build.py
pytest tests_e2e/test_demo_addon.py -v
Windows only — see Installation for running the same suite in GitHub Actions.
Want to help? Learn how to contribute to the ZirekHQ docs ›