nvda-addon-testkit 0.1.0__py3-none-any.whl

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.
nvda_testkit/plugin.py ADDED
@@ -0,0 +1,137 @@
1
+ """The pytest plugin. Registered automatically via the pytest11 entry point."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import warnings
6
+ from pathlib import Path
7
+
8
+ import pytest
9
+
10
+ from .client import NvdaClient
11
+ from .errors import ProvisionError
12
+ from .provisioning import Provisioned, provision, provision_fake
13
+ from .settings import load_settings
14
+
15
+
16
+ def pytest_addoption(parser: pytest.Parser) -> None:
17
+ group = parser.getgroup("nvda-testkit", "NVDA add-on end-to-end testing")
18
+ group.addoption("--nvda-channel", default=None, help="stable, beta, alpha, or a pinned version")
19
+ group.addoption("--nvda-version", default=None, help="alias for --nvda-channel <version>")
20
+ group.addoption("--nvda-timeout-scale", type=float, default=None, help="multiply every timeout")
21
+ group.addoption("--nvda-allow-eval", action="store_true", default=None, help="enable nvda.eval")
22
+ group.addoption(
23
+ "--nvda-keep-portable",
24
+ action="store_true",
25
+ default=None,
26
+ help="do not delete the portable NVDA copy on teardown",
27
+ )
28
+ group.addoption("--nvda-out-dir", default=None, help="where logs and artifacts are written")
29
+ group.addoption(
30
+ "--nvda-fake",
31
+ default=None,
32
+ help="drive the FakeNvda double at this path instead of a real NVDA (development only)",
33
+ )
34
+
35
+
36
+ def pytest_configure(config: pytest.Config) -> None:
37
+ config.addinivalue_line("markers", "fresh_nvda: restart NVDA before this test")
38
+
39
+ # Only one NVDA can own a desktop session, so parallel workers would fight
40
+ # over it and produce results that mean nothing.
41
+ workers = getattr(config.option, "numprocesses", None)
42
+ if isinstance(workers, int) and workers > 1:
43
+ raise pytest.UsageError(
44
+ f"nvda-addon-testkit cannot run in parallel: -n {workers} was requested, but only "
45
+ "one NVDA can own a desktop session. Shard across CI jobs instead."
46
+ )
47
+
48
+
49
+ @pytest.fixture(scope="session")
50
+ def nvda_settings(pytestconfig: pytest.Config):
51
+ option = pytestconfig.option
52
+ return load_settings(
53
+ overrides={
54
+ "channel": option.nvda_version or option.nvda_channel,
55
+ "timeout_scale": option.nvda_timeout_scale,
56
+ "allow_eval": True if option.nvda_allow_eval else None,
57
+ "keep_portable": True if option.nvda_keep_portable else None,
58
+ "out_dir": option.nvda_out_dir,
59
+ }
60
+ )
61
+
62
+
63
+ @pytest.fixture(scope="session")
64
+ def _nvda_provisioned(nvda_settings, pytestconfig: pytest.Config):
65
+ fake = pytestconfig.option.nvda_fake
66
+ running: Provisioned = (
67
+ provision_fake(nvda_settings, Path(fake)) if fake else provision(nvda_settings)
68
+ )
69
+ try:
70
+ yield running
71
+ finally:
72
+ running.teardown()
73
+
74
+
75
+ @pytest.fixture(scope="session")
76
+ def nvda_session(_nvda_provisioned, nvda_settings) -> NvdaClient:
77
+ return NvdaClient(_nvda_provisioned.process, _nvda_provisioned.rpc, settings=nvda_settings)
78
+
79
+
80
+ @pytest.fixture
81
+ def nvda(request: pytest.FixtureRequest, nvda_session: NvdaClient) -> NvdaClient:
82
+ if request.node.get_closest_marker("fresh_nvda"):
83
+ nvda_session.restart()
84
+ nvda_session.reset()
85
+ return nvda_session
86
+
87
+
88
+ @pytest.fixture(autouse=True)
89
+ def nvda_reset(request: pytest.FixtureRequest):
90
+ """Reset after any test that touched NVDA, so the next one starts clean.
91
+
92
+ Autouse, but deliberately lazy: a test that never asks for `nvda` never
93
+ causes NVDA to start.
94
+ """
95
+ yield
96
+ if "nvda_session" not in request.fixturenames:
97
+ return
98
+ client = request.getfixturevalue("nvda_session")
99
+ try:
100
+ client.reset()
101
+ except Exception as error:
102
+ # Non-fatal here -- the next test's `nvda` fixture calls reset() again
103
+ # and will surface a persistent failure -- but never silent: if this
104
+ # is the last test in the session, a warning is the only channel left.
105
+ warnings.warn(
106
+ f"nvda-testkit: reset() failed during teardown: {error}",
107
+ stacklevel=2,
108
+ )
109
+
110
+
111
+ @pytest.fixture(scope="session")
112
+ def addon_bundle(nvda_settings) -> Path:
113
+ pattern = nvda_settings.addon_bundle
114
+ if not pattern:
115
+ raise ProvisionError(
116
+ "No add-on bundle configured. Set addon-bundle under [tool.nvda-testkit] "
117
+ 'in pyproject.toml, e.g. addon-bundle = "dist/my-addon-*.nvda-addon".'
118
+ )
119
+ matches = sorted(Path().glob(pattern.replace("{version}", "*")))
120
+ if not matches:
121
+ raise ProvisionError(
122
+ f"No add-on bundle matched {pattern!r} from {Path().resolve()}. Build it first."
123
+ )
124
+ if len(matches) > 1:
125
+ raise ProvisionError(
126
+ f"{pattern!r} matched {len(matches)} bundles: {[str(m) for m in matches]}. "
127
+ "Clean the stale ones out, or pin the pattern."
128
+ )
129
+ return matches[0]
130
+
131
+
132
+ @pytest.fixture(scope="session")
133
+ def addon_under_test(nvda_session: NvdaClient, addon_bundle: Path) -> Path:
134
+ """The bundle, installed and enabled, with NVDA restarted to complete it."""
135
+ nvda_session.addons.install(addon_bundle)
136
+ nvda_session.restart()
137
+ return addon_bundle
@@ -0,0 +1,161 @@
1
+ """Create a disposable portable NVDA and seed it for testing.
2
+
3
+ Portable rather than a system install: no admin rights, several NVDA versions
4
+ side by side, and cleanup is deleting a directory. The trade-off, accepted
5
+ deliberately, is that the secure/lock screen is out of reach.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import subprocess
11
+ import sys
12
+ import zipfile
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ from .errors import ProvisionError, UnsupportedPlatformError
18
+
19
+ #: Settings applied to every portable copy the kit creates. Each one removes
20
+ #: something that would either block a headless run or add wall-clock time.
21
+ DEFAULT_CONFIG: dict[str, Any] = {
22
+ "general": {
23
+ "showWelcomeDialogAtStartup": False,
24
+ "saveConfigurationOnExit": False,
25
+ "askToExit": False,
26
+ "playStartAndExitSounds": False,
27
+ },
28
+ "update": {
29
+ "autoCheck": False,
30
+ "startupNotification": False,
31
+ "allowUsageStats": False,
32
+ # NVDA only skips AskAllowUsageStatsDialog once this is set; setting
33
+ # allowUsageStats alone still shows the blocking first-run prompt.
34
+ "askedAllowUsageStats": True,
35
+ },
36
+ "braille": {
37
+ "display": "noBraille",
38
+ },
39
+ "speechViewer": {
40
+ "showSpeechViewerAtStartup": False,
41
+ },
42
+ }
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class PortableNvda:
47
+ root: Path
48
+ exe: Path
49
+ user_config: Path
50
+ addons_dir: Path
51
+
52
+
53
+ def _render_section(name: str, values: dict[str, Any], depth: int) -> list[str]:
54
+ brackets = "[" * depth
55
+ closing = "]" * depth
56
+ lines = [f"{brackets}{name}{closing}"]
57
+ nested: list[str] = []
58
+ for key, value in values.items():
59
+ if isinstance(value, dict):
60
+ nested.extend(_render_section(key, value, depth + 1))
61
+ else:
62
+ lines.append(f"{key} = {value}")
63
+ return lines + nested
64
+
65
+
66
+ def _merge(base: dict[str, Any], overrides: dict[str, Any]) -> dict[str, Any]:
67
+ merged = {key: dict(value) if isinstance(value, dict) else value for key, value in base.items()}
68
+ for key, value in overrides.items():
69
+ if isinstance(value, dict) and isinstance(merged.get(key), dict):
70
+ merged[key] = _merge(merged[key], value)
71
+ else:
72
+ merged[key] = value
73
+ return merged
74
+
75
+
76
+ def render_nvda_ini(overrides: dict[str, Any] | None = None) -> str:
77
+ """Render an nvda.ini in ConfigObj's format.
78
+
79
+ schemaVersion is deliberately omitted: NVDA fills in its own defaults for a
80
+ partial config, which is more durable than pinning a number that changes
81
+ every release.
82
+ """
83
+ config = _merge(DEFAULT_CONFIG, overrides or {})
84
+ lines: list[str] = []
85
+ for name, values in config.items():
86
+ if not isinstance(values, dict):
87
+ lines.append(f"{name} = {values}")
88
+ continue
89
+ lines.extend(_render_section(name, values, depth=1))
90
+ return "\n".join(lines) + "\n"
91
+
92
+
93
+ def extract_addon(bundle: Path, dest_dir: Path) -> None:
94
+ """Unzip a .nvda-addon into dest_dir, refusing entries that escape it."""
95
+ bundle = Path(bundle)
96
+ dest_dir = Path(dest_dir)
97
+ dest_dir.mkdir(parents=True, exist_ok=True)
98
+ resolved_dest = dest_dir.resolve()
99
+
100
+ with zipfile.ZipFile(bundle) as archive:
101
+ names = archive.namelist()
102
+ for name in names:
103
+ target = (dest_dir / name).resolve()
104
+ if not target.is_relative_to(resolved_dest):
105
+ raise ProvisionError(
106
+ f"Entry {name!r} in {bundle} escapes the destination directory."
107
+ )
108
+ if "manifest.ini" not in names:
109
+ raise ProvisionError(f"{bundle} has no manifest.ini; it is not an NVDA add-on bundle.")
110
+ archive.extractall(dest_dir)
111
+
112
+
113
+ def seed_user_config(root: Path, overrides: dict[str, Any] | None = None) -> Path:
114
+ root = Path(root)
115
+ user_config = root / "userConfig"
116
+ (user_config / "addons").mkdir(parents=True, exist_ok=True)
117
+ (user_config / "nvda.ini").write_text(render_nvda_ini(overrides), encoding="utf-8")
118
+ return user_config
119
+
120
+
121
+ def create_portable(launcher: Path, dest: Path, *, timeout: float = 300) -> PortableNvda:
122
+ """Run the launcher to produce a portable copy at `dest`."""
123
+ if sys.platform != "win32":
124
+ raise UnsupportedPlatformError(
125
+ "Creating a portable NVDA copy needs a real Windows host. "
126
+ "Host-side logic is testable on Linux; this step is not."
127
+ )
128
+ launcher = Path(launcher)
129
+ dest = Path(dest)
130
+ if not launcher.is_file():
131
+ raise ProvisionError(f"Launcher not found: {launcher}")
132
+
133
+ try:
134
+ completed = subprocess.run(
135
+ [str(launcher), "--create-portable-silent", f"--portable-path={dest}"],
136
+ capture_output=True,
137
+ text=True,
138
+ timeout=timeout,
139
+ check=False,
140
+ )
141
+ except subprocess.TimeoutExpired as expired:
142
+ raise ProvisionError(
143
+ f"Creating a portable copy timed out after {timeout}s. Launcher: {launcher}\n"
144
+ f"stdout so far: {expired.stdout!r}\nstderr so far: {expired.stderr!r}"
145
+ ) from expired
146
+
147
+ exe = dest / "nvda.exe"
148
+ if completed.returncode != 0 or not exe.is_file():
149
+ raise ProvisionError(
150
+ f"Creating a portable copy failed (exit {completed.returncode}).\n"
151
+ f"stdout: {completed.stdout.strip()}\n"
152
+ f"stderr: {completed.stderr.strip()}"
153
+ )
154
+
155
+ user_config = seed_user_config(dest)
156
+ return PortableNvda(
157
+ root=dest,
158
+ exe=exe,
159
+ user_config=user_config,
160
+ addons_dir=user_config / "addons",
161
+ )
@@ -0,0 +1,287 @@
1
+ """Start, watch and stop the NVDA under test.
2
+
3
+ The handshake protocol: we generate a token, hand it to NVDA in the
4
+ environment, delete any stale handshake file, then poll for a new one while
5
+ also watching for the process dying. Watching both matters -- an NVDA that
6
+ crashes on startup must fail in seconds, not at the handshake deadline.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import contextlib
12
+ import json
13
+ import logging
14
+ import os
15
+ import secrets
16
+ import subprocess
17
+ import sys
18
+ import time
19
+ from collections.abc import Iterable, Sequence
20
+ from dataclasses import dataclass
21
+ from pathlib import Path
22
+
23
+ from .errors import HandshakeTimeout, NvdaStartupError
24
+ from .portable import PortableNvda
25
+
26
+ HANDSHAKE_FILENAME = "testkit-handshake.json"
27
+
28
+ _POLL_INTERVAL = 0.05
29
+
30
+
31
+ def new_token() -> str:
32
+ return secrets.token_hex(16)
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class Handshake:
37
+ port: int
38
+ pid: int
39
+ nvda_version: str
40
+ api_version: str | None
41
+ api_compat_to: str | None
42
+
43
+ @classmethod
44
+ def from_payload(cls, payload: dict) -> Handshake:
45
+ return cls(
46
+ port=int(payload["port"]),
47
+ pid=int(payload["pid"]),
48
+ nvda_version=str(payload.get("nvdaVersion", "unknown")),
49
+ api_version=payload.get("apiVersion"),
50
+ api_compat_to=payload.get("apiCompatTo"),
51
+ )
52
+
53
+
54
+ def nvda_argv(
55
+ portable: PortableNvda,
56
+ log_file: Path,
57
+ *,
58
+ # NVDA's --log-level takes one of the stdlib logging levels as an int
59
+ # (10/20/30/40/50), not a level name: a name fails argparse's type=int
60
+ # conversion and NVDA pops a blocking "Command-line Argument Error"
61
+ # dialog instead of exiting, hanging every real-NVDA test indefinitely.
62
+ log_level: int = logging.DEBUG,
63
+ minimal: bool = False,
64
+ extra: Iterable[str] = (),
65
+ ) -> list[str]:
66
+ argv = [
67
+ str(portable.exe),
68
+ f"--log-file={log_file}",
69
+ f"--log-level={log_level}",
70
+ f"--config-path={portable.user_config}",
71
+ # Leave the system screen-reader flag alone: the runner may be shared,
72
+ # and flipping it has effects that outlive the test session.
73
+ "--no-sr-flag",
74
+ ]
75
+ if minimal:
76
+ argv.append("--minimal")
77
+ argv.extend(extra)
78
+ return argv
79
+
80
+
81
+ class NvdaProcess:
82
+ """Owns one NVDA (or FakeNvda) process and its handshake."""
83
+
84
+ def __init__(
85
+ self,
86
+ argv: Sequence[str],
87
+ out_dir: Path,
88
+ *,
89
+ token: str | None = None,
90
+ env: dict[str, str] | None = None,
91
+ log_file: Path | None = None,
92
+ quit_via: str = "exe",
93
+ timeout_scale: float = 1.0,
94
+ ) -> None:
95
+ self.argv = list(argv)
96
+ # NVDA runs os.chdir(appDir) at startup (source/nvda.pyw), so a relative
97
+ # NVDA_TESTKIT_OUTDIR would resolve against the portable copy, not us.
98
+ self.out_dir = Path(out_dir).resolve()
99
+ self.token = token or new_token()
100
+ self._log_file_base = Path(log_file).resolve() if log_file else None
101
+ self.log_file = self._log_file_base
102
+ self._start_count = 0
103
+ self.timeout_scale = timeout_scale
104
+ self._quit_via = quit_via
105
+ self._extra_env = dict(env or {})
106
+ self._proc: subprocess.Popen | None = None
107
+ self._handshake: Handshake | None = None
108
+
109
+ @property
110
+ def handshake_path(self) -> Path:
111
+ return self.out_dir / HANDSHAKE_FILENAME
112
+
113
+ @property
114
+ def handshake(self) -> Handshake | None:
115
+ return self._handshake
116
+
117
+ @property
118
+ def is_running(self) -> bool:
119
+ return self._proc is not None and self._proc.poll() is None
120
+
121
+ def _environment(self) -> dict[str, str]:
122
+ environment = dict(os.environ)
123
+ environment.update(self._extra_env)
124
+ environment["NVDA_TESTKIT_TOKEN"] = self.token
125
+ environment["NVDA_TESTKIT_OUTDIR"] = str(self.out_dir)
126
+ return environment
127
+
128
+ def log_tail(self, lines: int = 80) -> str:
129
+ if self.log_file is None or not self.log_file.is_file():
130
+ return "(no NVDA log available)"
131
+ content = self.log_file.read_text(encoding="utf-8", errors="replace").splitlines()
132
+ return "\n".join(content[-lines:])
133
+
134
+ def _hang_diagnostics(self) -> str:
135
+ """Best-effort Windows process/AV snapshot, captured *before* kill().
136
+
137
+ A real NVDA that never even opens its own log file points at something
138
+ blocking before Python starts -- Defender's on-access scan of a
139
+ freshly-extracted .exe is the leading suspect on GH runners. Never
140
+ raises: a diagnostic that itself fails must not replace the original
141
+ HandshakeTimeout.
142
+ """
143
+ if sys.platform != "win32":
144
+ return ""
145
+ pid = self._proc.pid if self._proc is not None else None
146
+ sections = []
147
+ try:
148
+ tasklist = subprocess.run(
149
+ ["tasklist", "/V", "/FO", "CSV"],
150
+ capture_output=True,
151
+ text=True,
152
+ timeout=10,
153
+ check=False,
154
+ )
155
+ needles = [needle for needle in ("nvda", "werfault", f'"{pid}"') if needle]
156
+ matches = [
157
+ line
158
+ for line in tasklist.stdout.splitlines()
159
+ if any(needle in line.lower() for needle in needles)
160
+ ]
161
+ sections.append(
162
+ "tasklist (nvda/WerFault/our pid):\n" + ("\n".join(matches) or "(none found)")
163
+ )
164
+ except Exception as error:
165
+ sections.append(f"tasklist failed: {error}")
166
+ try:
167
+ defender = subprocess.run(
168
+ [
169
+ "powershell",
170
+ "-NoProfile",
171
+ "-NonInteractive",
172
+ "-Command",
173
+ "(Get-MpComputerStatus).RealTimeProtectionEnabled",
174
+ ],
175
+ capture_output=True,
176
+ text=True,
177
+ timeout=15,
178
+ check=False,
179
+ )
180
+ status = defender.stdout.strip() or defender.stderr.strip() or "(unknown)"
181
+ sections.append(f"Defender real-time protection enabled: {status}")
182
+ except Exception as error:
183
+ sections.append(f"Defender status check failed: {error}")
184
+ return "\n\n".join(sections)
185
+
186
+ def _number_log_file(self) -> None:
187
+ """Point --log-file at a fresh numbered file: NVDA truncates it on every start."""
188
+ if self._log_file_base is None:
189
+ return
190
+ base = self._log_file_base
191
+ self.log_file = base.with_name(f"{base.stem}-{self._start_count}{base.suffix}")
192
+ flag = f"--log-file={self.log_file}"
193
+ self.argv = [flag if arg.startswith("--log-file=") else arg for arg in self.argv]
194
+
195
+ def start(self, timeout: float = 60) -> Handshake:
196
+ deadline_seconds = timeout * self.timeout_scale
197
+ self.out_dir.mkdir(parents=True, exist_ok=True)
198
+ self.handshake_path.unlink(missing_ok=True)
199
+ self._handshake = None
200
+ self._start_count += 1
201
+ self._number_log_file()
202
+
203
+ self._proc = subprocess.Popen(self.argv, env=self._environment())
204
+
205
+ deadline = time.monotonic() + deadline_seconds
206
+ while time.monotonic() < deadline:
207
+ exit_code = self._proc.poll()
208
+ if exit_code is not None:
209
+ raise NvdaStartupError(
210
+ f"NVDA exited with code {exit_code} before handshaking.\n"
211
+ f"Command: {' '.join(self.argv)}\n"
212
+ f"Log tail:\n{self.log_tail()}"
213
+ )
214
+ if self.handshake_path.is_file():
215
+ try:
216
+ payload = json.loads(self.handshake_path.read_text(encoding="utf-8"))
217
+ except json.JSONDecodeError:
218
+ time.sleep(_POLL_INTERVAL)
219
+ continue
220
+ self._handshake = Handshake.from_payload(payload)
221
+ return self._handshake
222
+ time.sleep(_POLL_INTERVAL)
223
+
224
+ diagnostics = self._hang_diagnostics()
225
+ self.kill()
226
+ raise HandshakeTimeout(
227
+ f"NVDA started but never announced itself within {deadline_seconds:.1f}s. "
228
+ f"Expected {self.handshake_path}. Is the spy add-on installed and enabled?\n"
229
+ f"Log tail:\n{self.log_tail()}" + (f"\n\n{diagnostics}" if diagnostics else "")
230
+ )
231
+
232
+ def _request_quit(self) -> None:
233
+ if self._quit_via == "rpc":
234
+ if self._handshake is None:
235
+ return
236
+ import xmlrpc.client
237
+
238
+ proxy = xmlrpc.client.ServerProxy(
239
+ f"http://127.0.0.1:{self._handshake.port}", allow_none=True
240
+ )
241
+ with contextlib.suppress(Exception): # any failure here just falls through to kill
242
+ proxy.quit(self.token)
243
+ return
244
+ # Both branches must fall through to kill(). A --quit that itself
245
+ # hangs is exactly the case that guarantee exists for.
246
+ with contextlib.suppress(OSError, subprocess.TimeoutExpired):
247
+ subprocess.run(
248
+ [self.argv[0], "--quit"],
249
+ capture_output=True,
250
+ check=False,
251
+ timeout=30 * self.timeout_scale,
252
+ )
253
+
254
+ def quit(self, timeout: float = 30) -> None:
255
+ if self._proc is None:
256
+ return
257
+ if self._proc.poll() is not None:
258
+ self._proc = None
259
+ return
260
+
261
+ self._request_quit()
262
+
263
+ deadline = time.monotonic() + timeout * self.timeout_scale
264
+ while time.monotonic() < deadline:
265
+ if self._proc.poll() is not None:
266
+ self._proc = None
267
+ return
268
+ time.sleep(_POLL_INTERVAL)
269
+ # An NVDA that will not leave is still an NVDA holding the desktop
270
+ # session hostage for every later test. Kill it.
271
+ self.kill()
272
+
273
+ def kill(self) -> None:
274
+ if self._proc is None:
275
+ return
276
+ if self._proc.poll() is None:
277
+ self._proc.kill()
278
+ # kill() is called from except-handlers; a reap that times out here
279
+ # would replace the original failure (and its log tail) with itself.
280
+ with contextlib.suppress(subprocess.TimeoutExpired):
281
+ self._proc.wait(timeout=30)
282
+ self._proc = None
283
+ self._handshake = None
284
+
285
+ def restart(self, timeout: float = 60) -> Handshake:
286
+ self.quit(timeout=timeout)
287
+ return self.start(timeout=timeout)
@@ -0,0 +1,106 @@
1
+ """Assemble a running NVDA from settings.
2
+
3
+ Two paths: the real one, which needs Windows, and the FakeNvda one, which is
4
+ how the kit's own tests and a Linux developer exercise everything above the
5
+ transport.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import shutil
11
+ import sys
12
+ import tempfile
13
+ from pathlib import Path
14
+
15
+ from .download import ensure_launcher
16
+ from .errors import UnsupportedPlatformError
17
+ from .portable import create_portable, extract_addon
18
+ from .process import NvdaProcess, new_token, nvda_argv
19
+ from .resolve import resolve_launcher
20
+ from .rpcclient import RpcClient
21
+ from .settings import TestkitSettings
22
+ from .spybundle import require_spy_bundle
23
+
24
+ SPY_ADDON_DIRNAME = "nvda-testkit-spy"
25
+
26
+
27
+ class Provisioned:
28
+ """A running NVDA plus everything needed to tear it down."""
29
+
30
+ def __init__(self, process: NvdaProcess, rpc: RpcClient, workdir: Path, keep: bool) -> None:
31
+ self.process = process
32
+ self.rpc = rpc
33
+ self.workdir = workdir
34
+ self.keep = keep
35
+
36
+ def teardown(self) -> None:
37
+ # rpc.close() must never be able to strand a live NVDA process: if it
38
+ # raises, process.quit() (and then cleanup) must still run.
39
+ try:
40
+ self.rpc.close()
41
+ finally:
42
+ try:
43
+ self.process.quit(timeout=30)
44
+ finally:
45
+ if not self.keep:
46
+ shutil.rmtree(self.workdir, ignore_errors=True)
47
+
48
+
49
+ def provision_fake(settings: TestkitSettings, fake_script: Path) -> Provisioned:
50
+ workdir = Path(tempfile.mkdtemp(prefix="nvda-testkit-fake-"))
51
+ out_dir = workdir / "out"
52
+ out_dir.mkdir(parents=True, exist_ok=True)
53
+ token = new_token()
54
+ process = NvdaProcess(
55
+ [sys.executable, str(fake_script)],
56
+ out_dir,
57
+ token=token,
58
+ quit_via="rpc",
59
+ timeout_scale=settings.timeout_scale,
60
+ )
61
+ try:
62
+ handshake = process.start(timeout=60)
63
+ rpc = RpcClient.from_handshake(handshake, token=token, timeout_scale=settings.timeout_scale)
64
+ except Exception:
65
+ # Any failure past this point -- not just the timeout process.start()
66
+ # already guards -- must not leave the subprocess running.
67
+ process.kill()
68
+ raise
69
+ return Provisioned(process, rpc, workdir, keep=settings.keep_portable)
70
+
71
+
72
+ def provision(settings: TestkitSettings) -> Provisioned:
73
+ if sys.platform != "win32":
74
+ raise UnsupportedPlatformError(
75
+ "Driving a real NVDA needs Windows. On Linux, point the plugin at the "
76
+ "FakeNvda double with --nvda-fake to exercise everything above the transport."
77
+ )
78
+ info = resolve_launcher(settings.channel)
79
+ launcher = ensure_launcher(info)
80
+
81
+ workdir = Path(tempfile.mkdtemp(prefix="nvda-testkit-"))
82
+ # Resolved here too, not just in NvdaProcess, so log_file below is absolute:
83
+ # NVDA chdirs into its own directory before it opens either of them.
84
+ out_dir = settings.out_dir.resolve()
85
+ out_dir.mkdir(parents=True, exist_ok=True)
86
+
87
+ portable = create_portable(launcher, workdir / "nvda")
88
+ extract_addon(require_spy_bundle(), portable.addons_dir / SPY_ADDON_DIRNAME)
89
+
90
+ log_file = out_dir / "nvda.log"
91
+ token = new_token()
92
+ process = NvdaProcess(
93
+ nvda_argv(portable, log_file),
94
+ out_dir,
95
+ token=token,
96
+ log_file=log_file,
97
+ quit_via="exe",
98
+ timeout_scale=settings.timeout_scale,
99
+ )
100
+ try:
101
+ handshake = process.start(timeout=120)
102
+ rpc = RpcClient.from_handshake(handshake, token=token, timeout_scale=settings.timeout_scale)
103
+ except Exception:
104
+ process.kill()
105
+ raise
106
+ return Provisioned(process, rpc, workdir, keep=settings.keep_portable)