nvda-addon-testkit 0.1.3__tar.gz → 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. {nvda_addon_testkit-0.1.3 → nvda_addon_testkit-1.0.0}/PKG-INFO +13 -1
  2. {nvda_addon_testkit-0.1.3 → nvda_addon_testkit-1.0.0}/README.md +12 -0
  3. {nvda_addon_testkit-0.1.3 → nvda_addon_testkit-1.0.0}/docs/modules/ROOT/examples/tests_e2e/conftest.py +6 -0
  4. {nvda_addon_testkit-0.1.3 → nvda_addon_testkit-1.0.0}/docs/modules/ROOT/examples/tests_e2e/test_demo_addon.py +5 -5
  5. nvda_addon_testkit-1.0.0/docs/modules/ROOT/examples/tests_e2e/test_modal_dialog_investigation.py +67 -0
  6. {nvda_addon_testkit-0.1.3 → nvda_addon_testkit-1.0.0}/docs/modules/ROOT/examples/tests_e2e/test_smoke.py +8 -1
  7. {nvda_addon_testkit-0.1.3 → nvda_addon_testkit-1.0.0}/docs/modules/ROOT/pages/troubleshooting.adoc +1 -1
  8. nvda_addon_testkit-1.0.0/spy/globalPlugins/nvda_testkit_spy/eval_api.py +52 -0
  9. {nvda_addon_testkit-0.1.3 → nvda_addon_testkit-1.0.0}/spy/globalPlugins/nvda_testkit_spy/mainthread.py +11 -2
  10. {nvda_addon_testkit-0.1.3 → nvda_addon_testkit-1.0.0}/spy/globalPlugins/nvda_testkit_spy/registry.py +3 -1
  11. {nvda_addon_testkit-0.1.3 → nvda_addon_testkit-1.0.0}/src/nvda_testkit/_spy/nvda-testkit-spy.nvda-addon +0 -0
  12. {nvda_addon_testkit-0.1.3 → nvda_addon_testkit-1.0.0}/src/nvda_testkit/_version.py +2 -2
  13. {nvda_addon_testkit-0.1.3 → nvda_addon_testkit-1.0.0}/src/nvda_testkit/client.py +44 -2
  14. {nvda_addon_testkit-0.1.3 → nvda_addon_testkit-1.0.0}/src/nvda_testkit/errors.py +14 -0
  15. {nvda_addon_testkit-0.1.3 → nvda_addon_testkit-1.0.0}/src/nvda_testkit/namespaces/addons.py +1 -1
  16. {nvda_addon_testkit-0.1.3 → nvda_addon_testkit-1.0.0}/src/nvda_testkit/plugin.py +2 -2
  17. {nvda_addon_testkit-0.1.3 → nvda_addon_testkit-1.0.0}/src/nvda_testkit/process.py +55 -1
  18. {nvda_addon_testkit-0.1.3 → nvda_addon_testkit-1.0.0}/src/nvda_testkit/rpcclient.py +11 -4
  19. {nvda_addon_testkit-0.1.3 → nvda_addon_testkit-1.0.0}/tests/fake_nvda.py +5 -0
  20. nvda_addon_testkit-1.0.0/tests/test_client.py +312 -0
  21. {nvda_addon_testkit-0.1.3 → nvda_addon_testkit-1.0.0}/tests/test_process.py +113 -0
  22. {nvda_addon_testkit-0.1.3 → nvda_addon_testkit-1.0.0}/tests/test_rpcclient.py +23 -1
  23. {nvda_addon_testkit-0.1.3 → nvda_addon_testkit-1.0.0}/tests_spy/test_eval_api.py +38 -0
  24. {nvda_addon_testkit-0.1.3 → nvda_addon_testkit-1.0.0}/tests_spy/test_mainthread.py +34 -0
  25. nvda_addon_testkit-0.1.3/spy/globalPlugins/nvda_testkit_spy/eval_api.py +0 -35
  26. nvda_addon_testkit-0.1.3/tests/test_client.py +0 -123
  27. {nvda_addon_testkit-0.1.3 → nvda_addon_testkit-1.0.0}/.github/CODEOWNERS +0 -0
  28. {nvda_addon_testkit-0.1.3 → nvda_addon_testkit-1.0.0}/.github/CONTRIBUTING.md +0 -0
  29. {nvda_addon_testkit-0.1.3 → nvda_addon_testkit-1.0.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  30. {nvda_addon_testkit-0.1.3 → nvda_addon_testkit-1.0.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  31. {nvda_addon_testkit-0.1.3 → nvda_addon_testkit-1.0.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  32. {nvda_addon_testkit-0.1.3 → nvda_addon_testkit-1.0.0}/.github/pull_request_template.md +0 -0
  33. {nvda_addon_testkit-0.1.3 → nvda_addon_testkit-1.0.0}/.github/renovate.json +0 -0
  34. {nvda_addon_testkit-0.1.3 → nvda_addon_testkit-1.0.0}/.github/workflows/CI.yml +0 -0
  35. {nvda_addon_testkit-0.1.3 → nvda_addon_testkit-1.0.0}/.github/workflows/notify-docs.yml +0 -0
  36. {nvda_addon_testkit-0.1.3 → nvda_addon_testkit-1.0.0}/.github/workflows/prepare-release.yml +0 -0
  37. {nvda_addon_testkit-0.1.3 → nvda_addon_testkit-1.0.0}/.github/workflows/project-sync.yml +0 -0
  38. {nvda_addon_testkit-0.1.3 → nvda_addon_testkit-1.0.0}/.github/workflows/publish-python.yml +0 -0
  39. {nvda_addon_testkit-0.1.3 → nvda_addon_testkit-1.0.0}/.github/workflows/sonar-fork-coverage.yml +0 -0
  40. {nvda_addon_testkit-0.1.3 → nvda_addon_testkit-1.0.0}/.github/workflows/sonar-fork-scan.yml +0 -0
  41. {nvda_addon_testkit-0.1.3 → nvda_addon_testkit-1.0.0}/.github/workflows/sonar.yml +0 -0
  42. {nvda_addon_testkit-0.1.3 → nvda_addon_testkit-1.0.0}/.github/workflows/stale.yml +0 -0
  43. {nvda_addon_testkit-0.1.3 → nvda_addon_testkit-1.0.0}/.github/workflows/zizmor.yml +0 -0
  44. {nvda_addon_testkit-0.1.3 → nvda_addon_testkit-1.0.0}/.gitignore +0 -0
  45. {nvda_addon_testkit-0.1.3 → nvda_addon_testkit-1.0.0}/.sonarlint/connectedMode.json +0 -0
  46. {nvda_addon_testkit-0.1.3 → nvda_addon_testkit-1.0.0}/LICENSE +0 -0
  47. {nvda_addon_testkit-0.1.3 → nvda_addon_testkit-1.0.0}/action.yml +0 -0
  48. {nvda_addon_testkit-0.1.3 → nvda_addon_testkit-1.0.0}/docs/antora.yml +0 -0
  49. {nvda_addon_testkit-0.1.3 → nvda_addon_testkit-1.0.0}/docs/modules/ROOT/examples/demo-addon/build.py +0 -0
  50. {nvda_addon_testkit-0.1.3 → nvda_addon_testkit-1.0.0}/docs/modules/ROOT/examples/demo-addon/globalPlugins/testkit_demo.py +0 -0
  51. {nvda_addon_testkit-0.1.3 → nvda_addon_testkit-1.0.0}/docs/modules/ROOT/examples/demo-addon/manifest.ini +0 -0
  52. {nvda_addon_testkit-0.1.3 → nvda_addon_testkit-1.0.0}/docs/modules/ROOT/nav.adoc +0 -0
  53. {nvda_addon_testkit-0.1.3 → nvda_addon_testkit-1.0.0}/docs/modules/ROOT/pages/ci-guide.adoc +0 -0
  54. {nvda_addon_testkit-0.1.3 → nvda_addon_testkit-1.0.0}/docs/modules/ROOT/pages/contributing.adoc +0 -0
  55. {nvda_addon_testkit-0.1.3 → nvda_addon_testkit-1.0.0}/docs/modules/ROOT/pages/example-project.adoc +0 -0
  56. {nvda_addon_testkit-0.1.3 → nvda_addon_testkit-1.0.0}/docs/modules/ROOT/pages/guide/addons.adoc +0 -0
  57. {nvda_addon_testkit-0.1.3 → nvda_addon_testkit-1.0.0}/docs/modules/ROOT/pages/guide/braille.adoc +0 -0
  58. {nvda_addon_testkit-0.1.3 → nvda_addon_testkit-1.0.0}/docs/modules/ROOT/pages/guide/config.adoc +0 -0
  59. {nvda_addon_testkit-0.1.3 → nvda_addon_testkit-1.0.0}/docs/modules/ROOT/pages/guide/fixtures.adoc +0 -0
  60. {nvda_addon_testkit-0.1.3 → nvda_addon_testkit-1.0.0}/docs/modules/ROOT/pages/guide/keys.adoc +0 -0
  61. {nvda_addon_testkit-0.1.3 → nvda_addon_testkit-1.0.0}/docs/modules/ROOT/pages/guide/log.adoc +0 -0
  62. {nvda_addon_testkit-0.1.3 → nvda_addon_testkit-1.0.0}/docs/modules/ROOT/pages/guide/speech.adoc +0 -0
  63. {nvda_addon_testkit-0.1.3 → nvda_addon_testkit-1.0.0}/docs/modules/ROOT/pages/index.adoc +0 -0
  64. {nvda_addon_testkit-0.1.3 → nvda_addon_testkit-1.0.0}/docs/modules/ROOT/pages/installation.adoc +0 -0
  65. {nvda_addon_testkit-0.1.3 → nvda_addon_testkit-1.0.0}/docs/modules/ROOT/pages/tutorial.adoc +0 -0
  66. {nvda_addon_testkit-0.1.3 → nvda_addon_testkit-1.0.0}/pyproject.toml +0 -0
  67. {nvda_addon_testkit-0.1.3 → nvda_addon_testkit-1.0.0}/ruff.toml +0 -0
  68. {nvda_addon_testkit-0.1.3 → nvda_addon_testkit-1.0.0}/scripts/next-version.sh +0 -0
  69. {nvda_addon_testkit-0.1.3 → nvda_addon_testkit-1.0.0}/sonar-project.properties +0 -0
  70. {nvda_addon_testkit-0.1.3 → nvda_addon_testkit-1.0.0}/spy/globalPlugins/nvda_testkit_spy/__init__.py +0 -0
  71. {nvda_addon_testkit-0.1.3 → nvda_addon_testkit-1.0.0}/spy/globalPlugins/nvda_testkit_spy/addons_api.py +0 -0
  72. {nvda_addon_testkit-0.1.3 → nvda_addon_testkit-1.0.0}/spy/globalPlugins/nvda_testkit_spy/braille_tap.py +0 -0
  73. {nvda_addon_testkit-0.1.3 → nvda_addon_testkit-1.0.0}/spy/globalPlugins/nvda_testkit_spy/config_api.py +0 -0
  74. {nvda_addon_testkit-0.1.3 → nvda_addon_testkit-1.0.0}/spy/globalPlugins/nvda_testkit_spy/input_api.py +0 -0
  75. {nvda_addon_testkit-0.1.3 → nvda_addon_testkit-1.0.0}/spy/globalPlugins/nvda_testkit_spy/log_tap.py +0 -0
  76. {nvda_addon_testkit-0.1.3 → nvda_addon_testkit-1.0.0}/spy/globalPlugins/nvda_testkit_spy/serialise.py +0 -0
  77. {nvda_addon_testkit-0.1.3 → nvda_addon_testkit-1.0.0}/spy/globalPlugins/nvda_testkit_spy/server.py +0 -0
  78. {nvda_addon_testkit-0.1.3 → nvda_addon_testkit-1.0.0}/spy/globalPlugins/nvda_testkit_spy/speech_tap.py +0 -0
  79. {nvda_addon_testkit-0.1.3 → nvda_addon_testkit-1.0.0}/spy/manifest.ini +0 -0
  80. {nvda_addon_testkit-0.1.3 → nvda_addon_testkit-1.0.0}/src/nvda_testkit/__init__.py +0 -0
  81. {nvda_addon_testkit-0.1.3 → nvda_addon_testkit-1.0.0}/src/nvda_testkit/cli.py +0 -0
  82. {nvda_addon_testkit-0.1.3 → nvda_addon_testkit-1.0.0}/src/nvda_testkit/download.py +0 -0
  83. {nvda_addon_testkit-0.1.3 → nvda_addon_testkit-1.0.0}/src/nvda_testkit/namespaces/__init__.py +0 -0
  84. {nvda_addon_testkit-0.1.3 → nvda_addon_testkit-1.0.0}/src/nvda_testkit/namespaces/braille.py +0 -0
  85. {nvda_addon_testkit-0.1.3 → nvda_addon_testkit-1.0.0}/src/nvda_testkit/namespaces/config.py +0 -0
  86. {nvda_addon_testkit-0.1.3 → nvda_addon_testkit-1.0.0}/src/nvda_testkit/namespaces/keys.py +0 -0
  87. {nvda_addon_testkit-0.1.3 → nvda_addon_testkit-1.0.0}/src/nvda_testkit/namespaces/log.py +0 -0
  88. {nvda_addon_testkit-0.1.3 → nvda_addon_testkit-1.0.0}/src/nvda_testkit/namespaces/speech.py +0 -0
  89. {nvda_addon_testkit-0.1.3 → nvda_addon_testkit-1.0.0}/src/nvda_testkit/portable.py +0 -0
  90. {nvda_addon_testkit-0.1.3 → nvda_addon_testkit-1.0.0}/src/nvda_testkit/provisioning.py +0 -0
  91. {nvda_addon_testkit-0.1.3 → nvda_addon_testkit-1.0.0}/src/nvda_testkit/resolve.py +0 -0
  92. {nvda_addon_testkit-0.1.3 → nvda_addon_testkit-1.0.0}/src/nvda_testkit/settings.py +0 -0
  93. {nvda_addon_testkit-0.1.3 → nvda_addon_testkit-1.0.0}/src/nvda_testkit/speechtypes.py +0 -0
  94. {nvda_addon_testkit-0.1.3 → nvda_addon_testkit-1.0.0}/src/nvda_testkit/spybundle.py +0 -0
  95. {nvda_addon_testkit-0.1.3 → nvda_addon_testkit-1.0.0}/tests/conftest.py +0 -0
  96. {nvda_addon_testkit-0.1.3 → nvda_addon_testkit-1.0.0}/tests/fixtures/snapshot_index_alpha.html +0 -0
  97. {nvda_addon_testkit-0.1.3 → nvda_addon_testkit-1.0.0}/tests/fixtures/update_check_stable.txt +0 -0
  98. {nvda_addon_testkit-0.1.3 → nvda_addon_testkit-1.0.0}/tests/test_addons_namespace.py +0 -0
  99. {nvda_addon_testkit-0.1.3 → nvda_addon_testkit-1.0.0}/tests/test_braille_namespace.py +0 -0
  100. {nvda_addon_testkit-0.1.3 → nvda_addon_testkit-1.0.0}/tests/test_build_spy.py +0 -0
  101. {nvda_addon_testkit-0.1.3 → nvda_addon_testkit-1.0.0}/tests/test_cli.py +0 -0
  102. {nvda_addon_testkit-0.1.3 → nvda_addon_testkit-1.0.0}/tests/test_config_namespace.py +0 -0
  103. {nvda_addon_testkit-0.1.3 → nvda_addon_testkit-1.0.0}/tests/test_download.py +0 -0
  104. {nvda_addon_testkit-0.1.3 → nvda_addon_testkit-1.0.0}/tests/test_e2e_conftest.py +0 -0
  105. {nvda_addon_testkit-0.1.3 → nvda_addon_testkit-1.0.0}/tests/test_errors.py +0 -0
  106. {nvda_addon_testkit-0.1.3 → nvda_addon_testkit-1.0.0}/tests/test_fake_nvda.py +0 -0
  107. {nvda_addon_testkit-0.1.3 → nvda_addon_testkit-1.0.0}/tests/test_keys_namespace.py +0 -0
  108. {nvda_addon_testkit-0.1.3 → nvda_addon_testkit-1.0.0}/tests/test_log_namespace.py +0 -0
  109. {nvda_addon_testkit-0.1.3 → nvda_addon_testkit-1.0.0}/tests/test_plugin.py +0 -0
  110. {nvda_addon_testkit-0.1.3 → nvda_addon_testkit-1.0.0}/tests/test_portable.py +0 -0
  111. {nvda_addon_testkit-0.1.3 → nvda_addon_testkit-1.0.0}/tests/test_provisioning.py +0 -0
  112. {nvda_addon_testkit-0.1.3 → nvda_addon_testkit-1.0.0}/tests/test_resolve.py +0 -0
  113. {nvda_addon_testkit-0.1.3 → nvda_addon_testkit-1.0.0}/tests/test_settings.py +0 -0
  114. {nvda_addon_testkit-0.1.3 → nvda_addon_testkit-1.0.0}/tests/test_speech_namespace.py +0 -0
  115. {nvda_addon_testkit-0.1.3 → nvda_addon_testkit-1.0.0}/tests/test_speechtypes.py +0 -0
  116. {nvda_addon_testkit-0.1.3 → nvda_addon_testkit-1.0.0}/tests_spy/__init__.py +0 -0
  117. {nvda_addon_testkit-0.1.3 → nvda_addon_testkit-1.0.0}/tests_spy/conftest.py +0 -0
  118. {nvda_addon_testkit-0.1.3 → nvda_addon_testkit-1.0.0}/tests_spy/nvda_stubs.py +0 -0
  119. {nvda_addon_testkit-0.1.3 → nvda_addon_testkit-1.0.0}/tests_spy/test_addons_api.py +0 -0
  120. {nvda_addon_testkit-0.1.3 → nvda_addon_testkit-1.0.0}/tests_spy/test_braille_tap.py +0 -0
  121. {nvda_addon_testkit-0.1.3 → nvda_addon_testkit-1.0.0}/tests_spy/test_config_api.py +0 -0
  122. {nvda_addon_testkit-0.1.3 → nvda_addon_testkit-1.0.0}/tests_spy/test_input_api.py +0 -0
  123. {nvda_addon_testkit-0.1.3 → nvda_addon_testkit-1.0.0}/tests_spy/test_log_tap.py +0 -0
  124. {nvda_addon_testkit-0.1.3 → nvda_addon_testkit-1.0.0}/tests_spy/test_plugin.py +0 -0
  125. {nvda_addon_testkit-0.1.3 → nvda_addon_testkit-1.0.0}/tests_spy/test_registry.py +0 -0
  126. {nvda_addon_testkit-0.1.3 → nvda_addon_testkit-1.0.0}/tests_spy/test_serialise.py +0 -0
  127. {nvda_addon_testkit-0.1.3 → nvda_addon_testkit-1.0.0}/tests_spy/test_server.py +0 -0
  128. {nvda_addon_testkit-0.1.3 → nvda_addon_testkit-1.0.0}/tests_spy/test_speech_tap.py +0 -0
  129. {nvda_addon_testkit-0.1.3 → nvda_addon_testkit-1.0.0}/tools/build_spy.py +0 -0
  130. {nvda_addon_testkit-0.1.3 → nvda_addon_testkit-1.0.0}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: nvda-addon-testkit
3
- Version: 0.1.3
3
+ Version: 1.0.0
4
4
  Summary: End-to-end testing for NVDA add-ons against a real NVDA
5
5
  Project-URL: Homepage, https://github.com/ZirekHQ/nvda-addon-testkit
6
6
  Project-URL: Issues, https://github.com/ZirekHQ/nvda-addon-testkit/issues
@@ -94,6 +94,18 @@ still on Extended Security Updates).
94
94
  | `nvda.log` | structured log records, and `assert_no_errors()` |
95
95
  | `nvda.addons` | two-phase install, remove, and state |
96
96
 
97
+ `nvda.eval()` runs a single expression inside NVDA; `nvda.exec()` runs a
98
+ full multi-statement scenario and returns whatever it binds to
99
+ `__result__`. Both need `--nvda-allow-eval`. A bad scenario raises
100
+ `ScenarioSyntaxError`, so catching bare `except Exception: pass` around
101
+ either call still swallows it — catch the types you expect instead.
102
+
103
+ `nvda.restart_harness()` kills and relaunches the NVDA process — use it to
104
+ finish a two-phase add-on install or reset to a clean process. It does not
105
+ exercise NVDA's own restart logic. For that, use `nvda.restart_nvda()`,
106
+ which triggers NVDA's real `core.restart()` and waits for the replacement
107
+ process — needs `--nvda-allow-eval`, since it is built on `nvda.eval()`.
108
+
97
109
  ## Requirements
98
110
 
99
111
  Windows to run the tests. NVDA is downloaded automatically — you do not need
@@ -67,6 +67,18 @@ still on Extended Security Updates).
67
67
  | `nvda.log` | structured log records, and `assert_no_errors()` |
68
68
  | `nvda.addons` | two-phase install, remove, and state |
69
69
 
70
+ `nvda.eval()` runs a single expression inside NVDA; `nvda.exec()` runs a
71
+ full multi-statement scenario and returns whatever it binds to
72
+ `__result__`. Both need `--nvda-allow-eval`. A bad scenario raises
73
+ `ScenarioSyntaxError`, so catching bare `except Exception: pass` around
74
+ either call still swallows it — catch the types you expect instead.
75
+
76
+ `nvda.restart_harness()` kills and relaunches the NVDA process — use it to
77
+ finish a two-phase add-on install or reset to a clean process. It does not
78
+ exercise NVDA's own restart logic. For that, use `nvda.restart_nvda()`,
79
+ which triggers NVDA's real `core.restart()` and waits for the replacement
80
+ process — needs `--nvda-allow-eval`, since it is built on `nvda.eval()`.
81
+
70
82
  ## Requirements
71
83
 
72
84
  Windows to run the tests. NVDA is downloaded automatically — you do not need
@@ -65,3 +65,9 @@ def check_no_unexpected_errors(client, *, since: int = 0) -> None:
65
65
  @pytest.fixture
66
66
  def assert_no_unexpected_errors():
67
67
  return check_no_unexpected_errors
68
+
69
+
70
+ @pytest.fixture
71
+ def require_eval(pytestconfig):
72
+ if not pytestconfig.option.nvda_allow_eval:
73
+ pytest.skip("needs --nvda-allow-eval")
@@ -27,19 +27,19 @@ def test_install_is_two_phase_and_completes_on_restart(
27
27
  assert info.name == "testkit-demo"
28
28
  assert nvda.addons.state("testkit-demo") is AddonState.PENDING_INSTALL
29
29
 
30
- nvda.restart()
30
+ nvda.restart_harness()
31
31
  assert nvda.addons.state("testkit-demo") is AddonState.ENABLED
32
32
  assert_no_unexpected_errors(nvda)
33
33
 
34
34
  nvda.addons.remove("testkit-demo")
35
- nvda.restart()
35
+ nvda.restart_harness()
36
36
  assert nvda.addons.state("testkit-demo") is AddonState.NOT_INSTALLED
37
37
  # end::addons[]
38
38
 
39
39
 
40
40
  def test_the_installed_addon_logs_at_startup(nvda, addon_under_test):
41
41
  # tag::log[]
42
- nvda.restart()
42
+ nvda.restart_harness()
43
43
  nvda.log.wait_for(re.escape(STARTUP_MESSAGE), since=0, timeout=20)
44
44
  # end::log[]
45
45
 
@@ -53,7 +53,7 @@ def test_its_gesture_produces_the_expected_speech(nvda, addon_under_test):
53
53
 
54
54
  def test_it_survives_a_restart(nvda, addon_under_test):
55
55
  # tag::fixtures[]
56
- nvda.restart()
56
+ nvda.restart_harness()
57
57
  assert nvda.addons.state("testkit-demo") is AddonState.ENABLED
58
58
  before = nvda.speech.index()
59
59
  nvda.keys.press("NVDA+shift+control+d")
@@ -66,5 +66,5 @@ def test_removal_is_also_two_phase(nvda, addon_under_test):
66
66
  and that session-scoped fixture will not reinstall it."""
67
67
  nvda.addons.remove("testkit-demo")
68
68
  assert nvda.addons.state("testkit-demo") is AddonState.PENDING_REMOVE
69
- nvda.restart()
69
+ nvda.restart_harness()
70
70
  assert nvda.addons.state("testkit-demo") is AddonState.NOT_INSTALLED
@@ -0,0 +1,67 @@
1
+ """Investigation for issue #34, gaps 2 & 5: does a queued main-thread job
2
+ start while a real ShowModal() dialog is up?
3
+
4
+ This is not a regression test. It is a one-shot probe: run it once on
5
+ Windows CI, read the result, and act on it per the plan (either open a new
6
+ issue describing a real fix, or proceed with the simulate_modal() fallback
7
+ in the next task). Delete or keep this file once the investigation is
8
+ resolved -- it is not meant to run on every CI build.
9
+
10
+ Everything happens inside a single exec_in_nvda call, entirely on NVDA's
11
+ main thread: the spy's XML-RPC server (server.py) is a plain, unthreaded
12
+ SimpleXMLRPCServer, so two separate RPC connections can never be genuinely
13
+ concurrent at the transport level -- the second call simply can't be
14
+ dispatched until the first's handler returns. Queuing the second job from
15
+ *inside* the running scenario, via queueHandler.queueFunction directly,
16
+ avoids needing RPC-level concurrency at all: it tests whether NVDA's own
17
+ queue-draining mechanism still runs while the main thread is nested inside
18
+ ShowModal()'s event loop, using a timestamp comparison instead of a second
19
+ network round trip.
20
+
21
+ A third, lower-probability outcome is possible: if the dialog's own
22
+ wx.CallLater dismiss timer never fires for some unrelated reason, this
23
+ scenario never returns from ShowModal(), so nvda.exec() raises an RpcError
24
+ (wrapping exec_in_nvda's server-side "started on NVDA's main thread but did
25
+ not return within 30.0s" timeout) instead of returning a __result__ to
26
+ assert on. If you see that on a real run, it's worth investigating
27
+ separately -- it doesn't confirm or refute the queue-blocking hypothesis
28
+ either way.
29
+ """
30
+
31
+
32
+ def test_a_second_job_while_a_real_modal_is_up(require_eval, nvda):
33
+ scenario = (
34
+ "import queueHandler\n"
35
+ "import time\n"
36
+ "import wx\n"
37
+ "job_b_ran_at = []\n"
38
+ # exec_in_nvda runs this with separate globals/locals dicts (like a
39
+ # class body), so a nested def can't see job_b_ran_at/time as
40
+ # globals -- bind both as defaults, evaluated now, in this scope.
41
+ "def job_b(sink=job_b_ran_at, now=time.monotonic):\n"
42
+ " sink.append(now())\n"
43
+ "queueHandler.queueFunction(queueHandler.eventQueue, job_b)\n"
44
+ "dlg = wx.MessageDialog(None, 'probe', 'probe', wx.YES_NO)\n"
45
+ "wx.CallLater(1000, dlg.EndModal, wx.ID_YES)\n"
46
+ "before = time.monotonic()\n"
47
+ "dlg.ShowModal()\n"
48
+ "after = time.monotonic()\n"
49
+ "dlg.Destroy()\n"
50
+ "__result__ = {\n"
51
+ " 'ran_during_modal': bool(job_b_ran_at) and before <= job_b_ran_at[0] <= after,\n"
52
+ " 'ran_at_all': bool(job_b_ran_at),\n"
53
+ "}\n"
54
+ )
55
+
56
+ result = nvda.exec(scenario)
57
+
58
+ assert result["ran_during_modal"], (
59
+ "CONFIRMS THE DEADLOCK: a second main-thread job never ran while a "
60
+ f"real ShowModal() dialog was up (ran_at_all={result['ran_at_all']!r}). "
61
+ "Proceed with Task 7 (the simulate_modal() fallback)."
62
+ )
63
+ # If this assertion passes instead, the queue IS drained during
64
+ # ShowModal() in this NVDA version: STOP, do not proceed to Task 7, and
65
+ # open a new issue describing this finding plus what actually blocks
66
+ # input_api.py's keys_press() from dismissing the dialog (if anything
67
+ # still does).
@@ -37,5 +37,12 @@ def test_config_round_trips_through_a_real_nvda(nvda):
37
37
 
38
38
 
39
39
  def test_startup_produced_no_errors(nvda, assert_no_unexpected_errors):
40
- nvda.restart()
40
+ nvda.restart_harness()
41
+ assert_no_unexpected_errors(nvda)
42
+
43
+
44
+ def test_restart_nvda_exercises_core_restart(require_eval, nvda, assert_no_unexpected_errors):
45
+ old_pid = nvda.process.handshake.pid
46
+ nvda.restart_nvda(timeout=60)
47
+ assert nvda.process.handshake.pid != old_pid
41
48
  assert_no_unexpected_errors(nvda)
@@ -63,7 +63,7 @@ the current one; clean it out or narrow the pattern.
63
63
  fails.
64
64
 
65
65
  Install is two-phase by design — `install()` only reaches
66
- `PENDING_INSTALL`; a call to `nvda.restart()` is what completes it. See
66
+ `PENDING_INSTALL`; a call to `nvda.restart_harness()` is what completes it. See
67
67
  xref:guide/addons.adoc[].
68
68
 
69
69
  == Speech assertion mismatches
@@ -0,0 +1,52 @@
1
+ # coding: utf-8
2
+ """Run code inside NVDA's own process: one expression, or a scenario.
3
+
4
+ The host refuses to call either unless the session opted in, so the spy does
5
+ not second-guess it: the point is to reach NVDA's live state, which means
6
+ full builtins and real imports. Both run on the main thread for the same
7
+ reason every other mutation does.
8
+
9
+ eval_in_nvda evaluates a single expression and returns its value.
10
+ exec_in_nvda runs one or more statements and returns whatever the code bound
11
+ to a name called __result__, or None if it bound nothing -- multi-statement
12
+ scenarios (e.g. "import core; core.restart()") do not compile under eval()
13
+ and previously needed an unreadable immediately-invoked-lambda workaround.
14
+ """
15
+
16
+ import builtins
17
+
18
+ from .mainthread import run_on_main_thread
19
+ from .registry import rpc_method
20
+
21
+ _SCALARS = (str, int, float, bool, type(None))
22
+
23
+
24
+ def _marshallable(value):
25
+ """xmlrpc carries scalars and containers of scalars; everything else is a repr."""
26
+ if isinstance(value, _SCALARS):
27
+ return value
28
+ if isinstance(value, dict):
29
+ return {str(key): _marshallable(item) for key, item in value.items()}
30
+ if isinstance(value, (list, tuple, set, frozenset)):
31
+ return [_marshallable(item) for item in value]
32
+ return repr(value)
33
+
34
+
35
+ def _evaluate(source):
36
+ return eval(source, {"__builtins__": builtins})
37
+
38
+
39
+ @rpc_method
40
+ def eval_in_nvda(source, timeout=30.0):
41
+ return _marshallable(run_on_main_thread(lambda: _evaluate(source), timeout=timeout))
42
+
43
+
44
+ def _execute(source):
45
+ scope = {"__builtins__": builtins}
46
+ exec(compile(source, "<nvda-testkit>", "exec"), scope)
47
+ return scope.get("__result__")
48
+
49
+
50
+ @rpc_method
51
+ def exec_in_nvda(source, timeout=30.0):
52
+ return _marshallable(run_on_main_thread(lambda: _execute(source), timeout=timeout))
@@ -24,8 +24,10 @@ def run_on_main_thread(fn, timeout=DEFAULT_TIMEOUT):
24
24
  outcome = {"value": _MISSING, "error": None}
25
25
  finished = threading.Event()
26
26
  timed_out = threading.Event()
27
+ started = threading.Event()
27
28
 
28
29
  def runner():
30
+ started.set()
29
31
  try:
30
32
  outcome["value"] = fn()
31
33
  except BaseException as error: # NOSONAR -- forwarded verbatim, see raise below
@@ -38,9 +40,16 @@ def run_on_main_thread(fn, timeout=DEFAULT_TIMEOUT):
38
40
  queueHandler.queueFunction(queueHandler.eventQueue, runner)
39
41
  if not finished.wait(timeout):
40
42
  timed_out.set()
43
+ if started.is_set():
44
+ raise TimeoutError(
45
+ "%r started on NVDA's main thread but did not return "
46
+ "within %.1fs. It's hung, not queued behind something else."
47
+ % (getattr(fn, "__name__", fn), timeout)
48
+ )
41
49
  raise TimeoutError(
42
- "Timed out after %.1fs waiting for NVDA's main thread to run %r. "
43
- "NVDA is wedged or busy." % (timeout, getattr(fn, "__name__", fn))
50
+ "%r never started on NVDA's main thread within %.1fs. "
51
+ "The queue is backed up or NVDA is unresponsive to "
52
+ "queueFunction()." % (getattr(fn, "__name__", fn), timeout)
44
53
  )
45
54
  if outcome["error"] is not None:
46
55
  raise outcome["error"]
@@ -22,7 +22,9 @@ class Dispatcher:
22
22
  Faults are raised as plain exceptions; SimpleXMLRPCServer turns them into
23
23
  Faults whose faultString is "<Type>: <message>". The host matches on the
24
24
  "AUTH:" prefix, so those two strings are a wire contract -- do not reword
25
- them without changing rpcclient.py.
25
+ them without changing rpcclient.py. The "<TypeName>: <message>" shape of
26
+ that faultString is part of the same contract: rpcclient.py reads the type
27
+ name back out of it to recognise a SyntaxError as ScenarioSyntaxError.
26
28
  """
27
29
 
28
30
  def __init__(self, token):
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '0.1.3'
22
- __version_tuple__ = version_tuple = (0, 1, 3)
21
+ __version__ = version = '1.0.0'
22
+ __version_tuple__ = version_tuple = (1, 0, 0)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -2,10 +2,11 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import contextlib
5
6
  from dataclasses import dataclass
6
7
  from typing import Any
7
8
 
8
- from .errors import TestkitError
9
+ from .errors import ConnectionLost, TestkitError
9
10
  from .namespaces.addons import AddonsNamespace
10
11
  from .namespaces.braille import BrailleNamespace
11
12
  from .namespaces.config import ConfigNamespace
@@ -86,7 +87,9 @@ class NvdaClient:
86
87
  if failures:
87
88
  raise TestkitError("reset() failed for " + "; ".join(failures))
88
89
 
89
- def restart(self, *, timeout: float = 60.0) -> None:
90
+ def restart_harness(self, *, timeout: float = 60.0) -> None:
91
+ """Kill and relaunch the NVDA process. Does not exercise NVDA's own
92
+ core.restart() -- see restart_nvda() for that."""
90
93
  handshake = self._process.restart(timeout=timeout)
91
94
  self._rpc.close()
92
95
  self._attach(
@@ -97,6 +100,36 @@ class NvdaClient:
97
100
  )
98
101
  )
99
102
 
103
+ def restart_nvda(self, *, timeout: float = 60.0) -> None:
104
+ """Trigger NVDA's own core.restart() and wait for its replacement
105
+ process to announce itself. Requires allow_eval, since it is built
106
+ on eval() internally. Use this, not restart_harness(), to verify
107
+ behavior that lives in NVDA's real self-relaunch path."""
108
+ if not self._settings.allow_eval:
109
+ raise TestkitError(
110
+ "nvda.restart_nvda() is disabled. It runs arbitrary code inside NVDA, so it is "
111
+ "opt-in: pass --nvda-allow-eval, or set allow-eval = true under "
112
+ "[tool.nvda-testkit]."
113
+ )
114
+ handshake = self._process.handshake
115
+ if handshake is None:
116
+ raise TestkitError("NVDA is not running; nothing to restart.")
117
+ old_pid = handshake.pid
118
+ self._process.handshake_path.unlink(missing_ok=True)
119
+ with contextlib.suppress(ConnectionLost):
120
+ self.eval("__import__('core').restart()")
121
+ new_handshake = self._process.adopt_relaunched_handshake(
122
+ exclude_pid=old_pid, timeout=timeout
123
+ )
124
+ self._rpc.close()
125
+ self._attach(
126
+ RpcClient.from_handshake(
127
+ new_handshake,
128
+ token=self._process.token,
129
+ timeout_scale=self._settings.timeout_scale,
130
+ )
131
+ )
132
+
100
133
  def eval(self, source: str) -> Any:
101
134
  if not self._settings.allow_eval:
102
135
  raise TestkitError(
@@ -106,5 +139,14 @@ class NvdaClient:
106
139
  )
107
140
  return self._rpc.call("eval_in_nvda", source)
108
141
 
142
+ def exec(self, source: str) -> Any:
143
+ if not self._settings.allow_eval:
144
+ raise TestkitError(
145
+ "nvda.exec() is disabled. It runs arbitrary code inside NVDA, so it is "
146
+ "opt-in: pass --nvda-allow-eval, or set allow-eval = true under "
147
+ "[tool.nvda-testkit]."
148
+ )
149
+ return self._rpc.call("exec_in_nvda", source)
150
+
109
151
  def close(self) -> None:
110
152
  self._rpc.close()
@@ -46,6 +46,20 @@ class AuthError(RpcError):
46
46
  """The spy rejected our token. Almost always a stale NVDA from a previous run."""
47
47
 
48
48
 
49
+ class ConnectionLost(RpcError):
50
+ """The transport dropped before a response arrived -- expected when the
51
+ process answering the call is exiting (e.g. mid-restart)."""
52
+
53
+
54
+ class ScenarioSyntaxError(RpcError):
55
+ """`nvda.eval()`/`nvda.exec()` was given source that doesn't compile.
56
+
57
+ Distinguished from a plain RpcError so a scenario's own `except
58
+ Exception:` doesn't have to be the only thing standing between a typo
59
+ and a silently-skipped assertion.
60
+ """
61
+
62
+
49
63
  class WaitTimeout(TestkitError):
50
64
  """A deadline-bounded poll expired before its predicate came true."""
51
65
 
@@ -1,7 +1,7 @@
1
1
  """nvda.addons -- add-on install lifecycle.
2
2
 
3
3
  Installation is two-phase, and that is exposed rather than hidden: install()
4
- leaves the add-on PENDING_INSTALL, and only nvda.restart() makes it ENABLED.
4
+ leaves the add-on PENDING_INSTALL, and only nvda.restart_harness() makes it ENABLED.
5
5
  A test of installTasks.onInstall depends on being able to see both halves.
6
6
  """
7
7
 
@@ -78,7 +78,7 @@ def nvda_session(_nvda_provisioned, nvda_settings) -> NvdaClient:
78
78
  @pytest.fixture
79
79
  def nvda(request: pytest.FixtureRequest, nvda_session: NvdaClient) -> NvdaClient:
80
80
  if request.node.get_closest_marker("fresh_nvda"):
81
- nvda_session.restart()
81
+ nvda_session.restart_harness()
82
82
  nvda_session.reset()
83
83
  return nvda_session
84
84
 
@@ -128,5 +128,5 @@ def addon_bundle(nvda_settings) -> Path:
128
128
  def addon_under_test(nvda_session: NvdaClient, addon_bundle: Path) -> Path:
129
129
  """The bundle, installed and enabled, with NVDA restarted to complete it."""
130
130
  nvda_session.addons.install(addon_bundle)
131
- nvda_session.restart()
131
+ nvda_session.restart_harness()
132
132
  return addon_bundle
@@ -97,6 +97,7 @@ class NvdaProcess:
97
97
  self._extra_env = dict(env or {})
98
98
  self._proc: subprocess.Popen | None = None
99
99
  self._handshake: Handshake | None = None
100
+ self._owns_current_process = True
100
101
 
101
102
  @property
102
103
  def handshake_path(self) -> Path:
@@ -108,7 +109,9 @@ class NvdaProcess:
108
109
 
109
110
  @property
110
111
  def is_running(self) -> bool:
111
- return self._proc is not None and self._proc.poll() is None
112
+ if self._owns_current_process:
113
+ return self._proc is not None and self._proc.poll() is None
114
+ return self._handshake is not None
112
115
 
113
116
  def _environment(self) -> dict[str, str]:
114
117
  environment = dict(os.environ)
@@ -189,6 +192,7 @@ class NvdaProcess:
189
192
  self.out_dir.mkdir(parents=True, exist_ok=True)
190
193
  self.handshake_path.unlink(missing_ok=True)
191
194
  self._handshake = None
195
+ self._owns_current_process = True
192
196
  self._start_count += 1
193
197
  self._number_log_file()
194
198
 
@@ -221,6 +225,46 @@ class NvdaProcess:
221
225
  f"Log tail:\n{self.log_tail()}" + (f"\n\n{diagnostics}" if diagnostics else "")
222
226
  )
223
227
 
228
+ def adopt_relaunched_handshake(self, *, exclude_pid: int, timeout: float = 60) -> Handshake:
229
+ """Wait for NVDA's own replacement process to announce itself.
230
+
231
+ Unlike start(), this owns no subprocess handle to watch for an early
232
+ exit -- the process being waited for was not spawned by us, it was
233
+ spawned by the NVDA we are about to stop tracking via self._proc.
234
+ """
235
+ deadline_seconds = timeout * self.timeout_scale
236
+ deadline = time.monotonic() + deadline_seconds
237
+ while time.monotonic() < deadline:
238
+ if self.handshake_path.is_file():
239
+ try:
240
+ payload = json.loads(self.handshake_path.read_text(encoding="utf-8"))
241
+ except json.JSONDecodeError:
242
+ time.sleep(_POLL_INTERVAL)
243
+ continue
244
+ candidate = Handshake.from_payload(payload)
245
+ if candidate.pid != exclude_pid:
246
+ self._handshake = candidate
247
+ self._owns_current_process = False
248
+ return candidate
249
+ time.sleep(_POLL_INTERVAL)
250
+ raise HandshakeTimeout(
251
+ f"NVDA never announced a replacement process within {deadline_seconds:.1f}s of "
252
+ f"restarting (still waiting on a pid other than {exclude_pid}). "
253
+ f"Expected {self.handshake_path}."
254
+ )
255
+
256
+ def _kill_pid(self, pid: int) -> None:
257
+ """Best-effort: terminate a process we did not spawn ourselves."""
258
+ if sys.platform != "win32":
259
+ return
260
+ with contextlib.suppress(OSError, subprocess.TimeoutExpired):
261
+ subprocess.run(
262
+ ["taskkill", "/PID", str(pid), "/F"],
263
+ capture_output=True,
264
+ check=False,
265
+ timeout=30,
266
+ )
267
+
224
268
  def _request_quit(self) -> None:
225
269
  if self._quit_via == "rpc":
226
270
  if self._handshake is None:
@@ -242,6 +286,11 @@ class NvdaProcess:
242
286
  )
243
287
 
244
288
  def quit(self, timeout: float = 30) -> None:
289
+ if not self._owns_current_process:
290
+ if self._handshake is not None:
291
+ self._kill_pid(self._handshake.pid)
292
+ self._handshake = None
293
+ return
245
294
  if self._proc is None:
246
295
  return
247
296
  if self._proc.poll() is not None:
@@ -259,6 +308,11 @@ class NvdaProcess:
259
308
  self.kill()
260
309
 
261
310
  def kill(self) -> None:
311
+ if not self._owns_current_process:
312
+ if self._handshake is not None:
313
+ self._kill_pid(self._handshake.pid)
314
+ self._handshake = None
315
+ return
262
316
  if self._proc is None:
263
317
  return
264
318
  if self._proc.poll() is None:
@@ -8,13 +8,14 @@ answering this run's questions.
8
8
 
9
9
  from __future__ import annotations
10
10
 
11
+ import http.client
11
12
  import threading
12
13
  import time
13
14
  import xmlrpc.client
14
15
  from collections.abc import Callable
15
16
  from typing import Any
16
17
 
17
- from .errors import AuthError, RpcError, WaitTimeout
18
+ from .errors import AuthError, ConnectionLost, RpcError, ScenarioSyntaxError, WaitTimeout
18
19
  from .process import Handshake
19
20
 
20
21
  _DEFAULT_INTERVAL = 0.05
@@ -71,14 +72,20 @@ class RpcClient:
71
72
  "A stale NVDA from a previous run is the usual cause. "
72
73
  f"Remote said: {message}"
73
74
  ) from fault
75
+ if method in ("eval_in_nvda", "exec_in_nvda") and any(
76
+ name in message for name in ("SyntaxError", "IndentationError", "TabError")
77
+ ):
78
+ raise ScenarioSyntaxError(
79
+ f"{method}() was given source that doesn't compile: {message}"
80
+ ) from fault
74
81
  raise RpcError(f"{method}() failed inside NVDA: {message}") from fault
75
82
  except xmlrpc.client.ProtocolError as error:
76
- raise RpcError(
83
+ raise ConnectionLost(
77
84
  f"The spy answered {method!r} with HTTP {error.errcode} "
78
85
  f"{error.errmsg} at {error.url}."
79
86
  ) from error
80
- except OSError as error:
81
- raise RpcError(
87
+ except (OSError, http.client.HTTPException) as error:
88
+ raise ConnectionLost(
82
89
  f"Could not reach the spy on 127.0.0.1:{self.port} calling {method!r}. "
83
90
  "NVDA has probably died. "
84
91
  f"Transport error: {error}"
@@ -230,6 +230,11 @@ class FakeSpy:
230
230
  def rpc_eval_in_nvda(self, source, timeout=30.0):
231
231
  return _marshallable(eval(source, {"__builtins__": builtins}, {}))
232
232
 
233
+ def rpc_exec_in_nvda(self, source, timeout=30.0):
234
+ scope = {"__builtins__": builtins}
235
+ exec(compile(source, "<fake-nvda>", "exec"), scope)
236
+ return _marshallable(scope.get("__result__"))
237
+
233
238
  def rpc_addons_install(self, bundle_path, timeout=120.0):
234
239
  entry = {"name": "demo-addon", "version": "1.0.0", "state": "PENDING_INSTALL"}
235
240
  with self._lock: