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_addon_testkit-0.1.0.dist-info/METADATA +109 -0
- nvda_addon_testkit-0.1.0.dist-info/RECORD +27 -0
- nvda_addon_testkit-0.1.0.dist-info/WHEEL +4 -0
- nvda_addon_testkit-0.1.0.dist-info/entry_points.txt +5 -0
- nvda_addon_testkit-0.1.0.dist-info/licenses/LICENSE +338 -0
- nvda_testkit/__init__.py +5 -0
- nvda_testkit/_version.py +24 -0
- nvda_testkit/cli.py +92 -0
- nvda_testkit/client.py +114 -0
- nvda_testkit/download.py +73 -0
- nvda_testkit/errors.py +59 -0
- nvda_testkit/namespaces/__init__.py +1 -0
- nvda_testkit/namespaces/addons.py +58 -0
- nvda_testkit/namespaces/braille.py +57 -0
- nvda_testkit/namespaces/config.py +30 -0
- nvda_testkit/namespaces/keys.py +30 -0
- nvda_testkit/namespaces/log.py +95 -0
- nvda_testkit/namespaces/speech.py +76 -0
- nvda_testkit/plugin.py +137 -0
- nvda_testkit/portable.py +161 -0
- nvda_testkit/process.py +287 -0
- nvda_testkit/provisioning.py +106 -0
- nvda_testkit/resolve.py +126 -0
- nvda_testkit/rpcclient.py +122 -0
- nvda_testkit/settings.py +68 -0
- nvda_testkit/speechtypes.py +74 -0
- nvda_testkit/spybundle.py +24 -0
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
|
nvda_testkit/portable.py
ADDED
|
@@ -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
|
+
)
|
nvda_testkit/process.py
ADDED
|
@@ -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)
|