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/resolve.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Work out which NVDA launcher to download for a given channel.
|
|
2
|
+
|
|
3
|
+
Stable and beta come from nvaccess's update-check endpoint, which returns
|
|
4
|
+
`key: value` lines and always reports the newest build regardless of the
|
|
5
|
+
version we claim to be running. Alpha has no such endpoint -- passing
|
|
6
|
+
versionType=alpha returns a 404 -- so it is resolved by scraping the snapshot
|
|
7
|
+
directory index.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
import urllib.request
|
|
14
|
+
from collections.abc import Callable
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
|
|
17
|
+
from .errors import LauncherResolutionError
|
|
18
|
+
|
|
19
|
+
CHANNELS: tuple[str, ...] = ("stable", "beta", "alpha")
|
|
20
|
+
|
|
21
|
+
_UPDATE_CHECK_URL = (
|
|
22
|
+
"https://download.nvaccess.org/nvdaUpdateCheck"
|
|
23
|
+
"?autoCheck=false&versionType={channel}&version=2019.1"
|
|
24
|
+
)
|
|
25
|
+
_SNAPSHOT_INDEX_URL = "https://download.nvaccess.org/snapshots/alpha/"
|
|
26
|
+
_RELEASE_URL = "https://download.nvaccess.org/releases/{version}/nvda_{version}.exe"
|
|
27
|
+
|
|
28
|
+
_SNAPSHOT_RE = re.compile(r"nvda_snapshot_alpha-(\d+),([0-9a-f]+)\.exe")
|
|
29
|
+
_PINNED_RE = re.compile(r"^\d{4}\.\d+(\.\d+)?(beta\d+|rc\d+)?$")
|
|
30
|
+
|
|
31
|
+
_USER_AGENT = "nvda-addon-testkit"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class LauncherInfo:
|
|
36
|
+
"""Everything needed to fetch and verify one NVDA launcher.
|
|
37
|
+
|
|
38
|
+
`sha1` is None for alpha snapshots and pinned versions, which publish no
|
|
39
|
+
digest. Callers must treat None as "unverifiable", never as "verified".
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
channel: str
|
|
43
|
+
version: str
|
|
44
|
+
url: str
|
|
45
|
+
sha1: str | None
|
|
46
|
+
api_version: str | None
|
|
47
|
+
api_compat_to: str | None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def fetch_url(url: str) -> bytes:
|
|
51
|
+
request = urllib.request.Request(url, headers={"User-Agent": _USER_AGENT})
|
|
52
|
+
with urllib.request.urlopen(request, timeout=60) as response:
|
|
53
|
+
return response.read()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _parse_update_check(body: str) -> dict[str, str]:
|
|
57
|
+
fields: dict[str, str] = {}
|
|
58
|
+
for line in body.splitlines():
|
|
59
|
+
key, separator, value = line.partition(":")
|
|
60
|
+
if separator:
|
|
61
|
+
fields[key.strip()] = value.strip()
|
|
62
|
+
return fields
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _resolve_released(channel: str, fetch: Callable[[str], bytes]) -> LauncherInfo:
|
|
66
|
+
url = _UPDATE_CHECK_URL.format(channel=channel)
|
|
67
|
+
fields = _parse_update_check(fetch(url).decode("utf-8", errors="replace"))
|
|
68
|
+
launcher_url = fields.get("launcherUrl")
|
|
69
|
+
if not launcher_url:
|
|
70
|
+
raise LauncherResolutionError(
|
|
71
|
+
f"The update-check endpoint returned no launcherUrl for channel {channel!r}. "
|
|
72
|
+
f"Got fields: {sorted(fields)}"
|
|
73
|
+
)
|
|
74
|
+
return LauncherInfo(
|
|
75
|
+
channel=channel,
|
|
76
|
+
version=fields.get("version", "unknown"),
|
|
77
|
+
url=launcher_url,
|
|
78
|
+
sha1=fields.get("launcherHash"),
|
|
79
|
+
api_version=fields.get("apiVersion"),
|
|
80
|
+
api_compat_to=fields.get("apiCompatTo"),
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _resolve_alpha(fetch: Callable[[str], bytes]) -> LauncherInfo:
|
|
85
|
+
html = fetch(_SNAPSHOT_INDEX_URL).decode("utf-8", errors="replace")
|
|
86
|
+
matches = _SNAPSHOT_RE.findall(html)
|
|
87
|
+
if not matches:
|
|
88
|
+
raise LauncherResolutionError(
|
|
89
|
+
f"Found no alpha snapshot filenames at {_SNAPSHOT_INDEX_URL}. "
|
|
90
|
+
"The directory layout may have changed."
|
|
91
|
+
)
|
|
92
|
+
revision, commit = max(matches, key=lambda pair: int(pair[0]))
|
|
93
|
+
filename = f"nvda_snapshot_alpha-{revision},{commit}.exe"
|
|
94
|
+
return LauncherInfo(
|
|
95
|
+
channel="alpha",
|
|
96
|
+
version=f"alpha-{revision},{commit}",
|
|
97
|
+
url=_SNAPSHOT_INDEX_URL + filename,
|
|
98
|
+
sha1=None,
|
|
99
|
+
api_version=None,
|
|
100
|
+
api_compat_to=None,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def resolve_launcher(
|
|
105
|
+
channel: str = "stable",
|
|
106
|
+
*,
|
|
107
|
+
fetch: Callable[[str], bytes] = fetch_url,
|
|
108
|
+
) -> LauncherInfo:
|
|
109
|
+
"""Resolve a channel name, or a pinned version like "2026.1.1", to a launcher."""
|
|
110
|
+
if channel in ("stable", "beta"):
|
|
111
|
+
return _resolve_released(channel, fetch)
|
|
112
|
+
if channel == "alpha":
|
|
113
|
+
return _resolve_alpha(fetch)
|
|
114
|
+
if _PINNED_RE.match(channel):
|
|
115
|
+
return LauncherInfo(
|
|
116
|
+
channel="pinned",
|
|
117
|
+
version=channel,
|
|
118
|
+
url=_RELEASE_URL.format(version=channel),
|
|
119
|
+
sha1=None,
|
|
120
|
+
api_version=None,
|
|
121
|
+
api_compat_to=None,
|
|
122
|
+
)
|
|
123
|
+
raise LauncherResolutionError(
|
|
124
|
+
f"{channel!r} is not a known channel. Expected one of {CHANNELS} "
|
|
125
|
+
"or a pinned version such as '2026.1.1'."
|
|
126
|
+
)
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""XML-RPC transport to the spy add-on.
|
|
2
|
+
|
|
3
|
+
Standard library only, on both ends: NVDA's bundled Python has xmlrpc, so the
|
|
4
|
+
spy add-on can be dependency-free. Every call carries the session token as its
|
|
5
|
+
first argument, which is what stops a stale NVDA from a previous run quietly
|
|
6
|
+
answering this run's questions.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import threading
|
|
12
|
+
import time
|
|
13
|
+
import xmlrpc.client
|
|
14
|
+
from collections.abc import Callable
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from .errors import AuthError, RpcError, WaitTimeout
|
|
18
|
+
from .process import Handshake
|
|
19
|
+
|
|
20
|
+
_DEFAULT_INTERVAL = 0.05
|
|
21
|
+
|
|
22
|
+
#: Ceiling on any single call. Must exceed the longest server-side operation
|
|
23
|
+
#: (addons_install allows 120s). Finite matters more than the exact value: a
|
|
24
|
+
#: wedged NVDA would otherwise block forever and hang CI with no diagnostic.
|
|
25
|
+
_SOCKET_TIMEOUT = 300.0
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class _TimeoutTransport(xmlrpc.client.Transport):
|
|
29
|
+
def __init__(self, timeout: float) -> None:
|
|
30
|
+
super().__init__(use_builtin_types=True)
|
|
31
|
+
self._timeout = timeout
|
|
32
|
+
|
|
33
|
+
def make_connection(self, host):
|
|
34
|
+
connection = super().make_connection(host)
|
|
35
|
+
connection.timeout = self._timeout
|
|
36
|
+
return connection
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class RpcClient:
|
|
40
|
+
def __init__(
|
|
41
|
+
self,
|
|
42
|
+
port: int,
|
|
43
|
+
token: str,
|
|
44
|
+
*,
|
|
45
|
+
timeout_scale: float = 1.0,
|
|
46
|
+
socket_timeout: float = _SOCKET_TIMEOUT,
|
|
47
|
+
) -> None:
|
|
48
|
+
self.port = port
|
|
49
|
+
self.token = token
|
|
50
|
+
self.timeout_scale = timeout_scale
|
|
51
|
+
self._proxy = xmlrpc.client.ServerProxy(
|
|
52
|
+
f"http://127.0.0.1:{port}",
|
|
53
|
+
allow_none=True,
|
|
54
|
+
use_builtin_types=True,
|
|
55
|
+
transport=_TimeoutTransport(socket_timeout * timeout_scale),
|
|
56
|
+
)
|
|
57
|
+
# ServerProxy reuses a single HTTP connection, which isn't safe for
|
|
58
|
+
# concurrent requests (e.g. wait_for polling while another thread emits).
|
|
59
|
+
self._lock = threading.Lock()
|
|
60
|
+
|
|
61
|
+
@classmethod
|
|
62
|
+
def from_handshake(
|
|
63
|
+
cls, handshake: Handshake, *, token: str, timeout_scale: float = 1.0
|
|
64
|
+
) -> RpcClient:
|
|
65
|
+
return cls(handshake.port, token, timeout_scale=timeout_scale)
|
|
66
|
+
|
|
67
|
+
def call(self, method: str, *args: Any) -> Any:
|
|
68
|
+
try:
|
|
69
|
+
with self._lock:
|
|
70
|
+
return getattr(self._proxy, method)(self.token, *args)
|
|
71
|
+
except xmlrpc.client.Fault as fault:
|
|
72
|
+
message = fault.faultString
|
|
73
|
+
if "AUTH:" in message:
|
|
74
|
+
raise AuthError(
|
|
75
|
+
f"The spy rejected our token calling {method!r}. "
|
|
76
|
+
"A stale NVDA from a previous run is the usual cause. "
|
|
77
|
+
f"Remote said: {message}"
|
|
78
|
+
) from fault
|
|
79
|
+
raise RpcError(f"{method}() failed inside NVDA: {message}") from fault
|
|
80
|
+
except xmlrpc.client.ProtocolError as error:
|
|
81
|
+
# Not an OSError, so it would otherwise escape untranslated.
|
|
82
|
+
raise RpcError(
|
|
83
|
+
f"The spy answered {method!r} with HTTP {error.errcode} "
|
|
84
|
+
f"{error.errmsg} at {error.url}."
|
|
85
|
+
) from error
|
|
86
|
+
except OSError as error:
|
|
87
|
+
raise RpcError(
|
|
88
|
+
f"Could not reach the spy on 127.0.0.1:{self.port} calling {method!r}. "
|
|
89
|
+
"NVDA has probably died. "
|
|
90
|
+
f"Transport error: {error}"
|
|
91
|
+
) from error
|
|
92
|
+
|
|
93
|
+
def poll_until(
|
|
94
|
+
self,
|
|
95
|
+
fn: Callable[[], Any],
|
|
96
|
+
*,
|
|
97
|
+
timeout: float,
|
|
98
|
+
description: str,
|
|
99
|
+
interval: float = _DEFAULT_INTERVAL,
|
|
100
|
+
last_seen: Callable[[], Any] | None = None,
|
|
101
|
+
) -> Any:
|
|
102
|
+
"""Poll `fn` until it returns something truthy, or the deadline passes."""
|
|
103
|
+
scaled = timeout * self.timeout_scale
|
|
104
|
+
deadline = time.monotonic() + scaled
|
|
105
|
+
while True:
|
|
106
|
+
result = fn()
|
|
107
|
+
if result:
|
|
108
|
+
return result
|
|
109
|
+
if time.monotonic() >= deadline:
|
|
110
|
+
observed = None
|
|
111
|
+
if last_seen is not None:
|
|
112
|
+
try:
|
|
113
|
+
observed = last_seen()
|
|
114
|
+
except RpcError:
|
|
115
|
+
observed = "(unavailable: NVDA unreachable)"
|
|
116
|
+
raise WaitTimeout(description, scaled, observed)
|
|
117
|
+
time.sleep(interval)
|
|
118
|
+
|
|
119
|
+
def close(self) -> None:
|
|
120
|
+
transport = getattr(self._proxy, "_ServerProxy__transport", None)
|
|
121
|
+
if transport is not None:
|
|
122
|
+
transport.close()
|
nvda_testkit/settings.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Configuration from [tool.nvda-testkit] in pyproject.toml, plus CLI overrides.
|
|
2
|
+
|
|
3
|
+
tomllib is stdlib from 3.11, which is the floor -- no external TOML dependency.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import tomllib
|
|
9
|
+
from dataclasses import dataclass, replace
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import cast
|
|
12
|
+
|
|
13
|
+
_KEY_MAP = {
|
|
14
|
+
"nvda-channel": "channel",
|
|
15
|
+
"addon-bundle": "addon_bundle",
|
|
16
|
+
"modules": "modules",
|
|
17
|
+
"allow-eval": "allow_eval",
|
|
18
|
+
"timeout-scale": "timeout_scale",
|
|
19
|
+
"keep-portable": "keep_portable",
|
|
20
|
+
"out-dir": "out_dir",
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class TestkitSettings:
|
|
26
|
+
channel: str = "stable"
|
|
27
|
+
addon_bundle: str | None = None
|
|
28
|
+
modules: tuple[str, ...] = ()
|
|
29
|
+
allow_eval: bool = False
|
|
30
|
+
timeout_scale: float = 1.0
|
|
31
|
+
keep_portable: bool = False
|
|
32
|
+
out_dir: Path = Path("testOutput")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _coerce(field: str, value):
|
|
36
|
+
if field == "modules":
|
|
37
|
+
return tuple(value)
|
|
38
|
+
if field == "out_dir":
|
|
39
|
+
return Path(value)
|
|
40
|
+
if field == "timeout_scale":
|
|
41
|
+
return float(value)
|
|
42
|
+
if field in ("allow_eval", "keep_portable"):
|
|
43
|
+
return bool(value)
|
|
44
|
+
return value
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def load_settings(
|
|
48
|
+
pyproject: Path | None = None,
|
|
49
|
+
overrides: dict | None = None,
|
|
50
|
+
) -> TestkitSettings:
|
|
51
|
+
path = Path(pyproject) if pyproject is not None else Path("pyproject.toml")
|
|
52
|
+
values: dict = {}
|
|
53
|
+
|
|
54
|
+
if path.is_file():
|
|
55
|
+
with path.open("rb") as handle:
|
|
56
|
+
document = tomllib.load(handle)
|
|
57
|
+
section = document.get("tool", {}).get("nvda-testkit", {})
|
|
58
|
+
for key, value in section.items():
|
|
59
|
+
field = _KEY_MAP.get(key)
|
|
60
|
+
if field:
|
|
61
|
+
values[field] = _coerce(field, value)
|
|
62
|
+
|
|
63
|
+
# A None override means "not specified on the command line", not "clear it".
|
|
64
|
+
for field, value in (overrides or {}).items():
|
|
65
|
+
if value is not None:
|
|
66
|
+
values[field] = _coerce(field, value)
|
|
67
|
+
|
|
68
|
+
return cast(TestkitSettings, replace(TestkitSettings(), **values))
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Host-side representation of a captured speech sequence.
|
|
2
|
+
|
|
3
|
+
Deliberately not NVDA's own SpeechSequence: these come off the wire as plain
|
|
4
|
+
dicts, and the point is that a test can assert on them without importing
|
|
5
|
+
anything from NVDA.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class SpeechItem:
|
|
17
|
+
kind: str
|
|
18
|
+
text: str | None = None
|
|
19
|
+
command_type: str | None = None
|
|
20
|
+
fields: dict[str, Any] = field(default_factory=dict)
|
|
21
|
+
raw: str | None = None
|
|
22
|
+
|
|
23
|
+
@classmethod
|
|
24
|
+
def from_payload(cls, payload: dict) -> SpeechItem:
|
|
25
|
+
if payload.get("kind") == "text":
|
|
26
|
+
return cls(kind="text", text=payload.get("text", ""))
|
|
27
|
+
return cls(
|
|
28
|
+
kind="command",
|
|
29
|
+
command_type=payload.get("type"),
|
|
30
|
+
fields=dict(payload.get("fields") or {}),
|
|
31
|
+
raw=payload.get("repr"),
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
def __repr__(self) -> str:
|
|
35
|
+
if self.kind == "text":
|
|
36
|
+
return f"text({self.text!r})"
|
|
37
|
+
return f"{self.command_type}({self.fields})"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class SpeechSequence:
|
|
42
|
+
items: tuple[SpeechItem, ...]
|
|
43
|
+
timestamp: float = 0.0
|
|
44
|
+
cancelled: bool = False
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def text(self) -> str:
|
|
48
|
+
# Space-join then collapse. NVDA emits both ["Install", "button"] and
|
|
49
|
+
# ["Install", " ", "button"]; a plain " ".join doubles the space in the
|
|
50
|
+
# second shape and breaks the regex a test author would naturally write.
|
|
51
|
+
joined = " ".join(item.text for item in self.items if item.kind == "text" and item.text)
|
|
52
|
+
return re.sub(r"\s+", " ", joined).strip()
|
|
53
|
+
|
|
54
|
+
def commands(self, type_name: str) -> list[SpeechItem]:
|
|
55
|
+
return [item for item in self.items if item.command_type == type_name]
|
|
56
|
+
|
|
57
|
+
def matches(self, pattern: str, *, flags: int = re.IGNORECASE) -> bool:
|
|
58
|
+
return re.search(pattern, self.text, flags) is not None
|
|
59
|
+
|
|
60
|
+
def __repr__(self) -> str:
|
|
61
|
+
suffix = " [cancelled]" if self.cancelled else ""
|
|
62
|
+
return f"<SpeechSequence {self.text!r}{suffix}>"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def parse_sequence(payload: dict) -> SpeechSequence:
|
|
66
|
+
return SpeechSequence(
|
|
67
|
+
items=tuple(SpeechItem.from_payload(item) for item in payload.get("items") or []),
|
|
68
|
+
timestamp=float(payload.get("timestamp") or 0.0),
|
|
69
|
+
cancelled=bool(payload.get("cancelled")),
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def parse_sequences(payloads: list[dict]) -> list[SpeechSequence]:
|
|
74
|
+
return [parse_sequence(payload) for payload in payloads]
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Locate the spy add-on bundle shipped inside the wheel."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from .errors import ProvisionError
|
|
8
|
+
|
|
9
|
+
BUNDLE_NAME = "nvda-testkit-spy.nvda-addon"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def spy_bundle_path() -> Path:
|
|
13
|
+
bundle = Path(__file__).parent / "_spy" / BUNDLE_NAME
|
|
14
|
+
return bundle
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def require_spy_bundle() -> Path:
|
|
18
|
+
bundle = spy_bundle_path()
|
|
19
|
+
if not bundle.is_file():
|
|
20
|
+
raise ProvisionError(
|
|
21
|
+
f"The spy add-on bundle is missing at {bundle}. "
|
|
22
|
+
"In a source checkout, run: python tools/build_spy.py"
|
|
23
|
+
)
|
|
24
|
+
return bundle
|