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/client.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""NvdaClient -- the object every test receives as the `nvda` fixture."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .errors import TestkitError
|
|
9
|
+
from .namespaces.addons import AddonsNamespace
|
|
10
|
+
from .namespaces.braille import BrailleNamespace
|
|
11
|
+
from .namespaces.config import ConfigNamespace
|
|
12
|
+
from .namespaces.keys import KeysNamespace
|
|
13
|
+
from .namespaces.log import LogNamespace
|
|
14
|
+
from .namespaces.speech import SpeechNamespace
|
|
15
|
+
from .process import NvdaProcess
|
|
16
|
+
from .rpcclient import RpcClient
|
|
17
|
+
from .settings import TestkitSettings
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class NvdaVersion:
|
|
22
|
+
version: str
|
|
23
|
+
api_version: str | None
|
|
24
|
+
api_compat_to: str | None
|
|
25
|
+
channel: str = "unknown"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class NvdaClient:
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
process: NvdaProcess,
|
|
32
|
+
rpc: RpcClient,
|
|
33
|
+
settings: TestkitSettings | None = None,
|
|
34
|
+
) -> None:
|
|
35
|
+
self._process = process
|
|
36
|
+
self._rpc = rpc
|
|
37
|
+
self._settings = settings or TestkitSettings()
|
|
38
|
+
self._attach(rpc)
|
|
39
|
+
# Taken once, at connect time, and reused by every reset(). Capturing it
|
|
40
|
+
# per-test would bake in whatever the previous test left behind.
|
|
41
|
+
self._baseline_config = self.config.snapshot()
|
|
42
|
+
|
|
43
|
+
def _attach(self, rpc: RpcClient) -> None:
|
|
44
|
+
self._rpc = rpc
|
|
45
|
+
self.addons = AddonsNamespace(rpc)
|
|
46
|
+
self.speech = SpeechNamespace(rpc)
|
|
47
|
+
self.braille = BrailleNamespace(rpc)
|
|
48
|
+
self.keys = KeysNamespace(rpc)
|
|
49
|
+
self.config = ConfigNamespace(rpc)
|
|
50
|
+
self.log = LogNamespace(rpc)
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def rpc(self) -> RpcClient:
|
|
54
|
+
return self._rpc
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def process(self) -> NvdaProcess:
|
|
58
|
+
return self._process
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def version(self) -> NvdaVersion:
|
|
62
|
+
handshake = self._process.handshake
|
|
63
|
+
if handshake is None:
|
|
64
|
+
raise TestkitError("NVDA is not running; no version information available.")
|
|
65
|
+
return NvdaVersion(
|
|
66
|
+
version=handshake.nvda_version,
|
|
67
|
+
api_version=handshake.api_version,
|
|
68
|
+
api_compat_to=handshake.api_compat_to,
|
|
69
|
+
channel=self._settings.channel,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
def wait_until_idle(self, *, timeout: float = 10.0) -> None:
|
|
73
|
+
self._rpc.call("wait_until_idle", timeout)
|
|
74
|
+
|
|
75
|
+
def reset(self) -> None:
|
|
76
|
+
"""Return NVDA to the state a test should start from."""
|
|
77
|
+
# Attempt every step even if one fails, then report. A partial reset is
|
|
78
|
+
# worse than a failed one: it is inherited silently by the next test.
|
|
79
|
+
failures = []
|
|
80
|
+
for label, step in (
|
|
81
|
+
("speech", self.speech.clear),
|
|
82
|
+
("braille", self.braille.clear),
|
|
83
|
+
("log", self.log.clear),
|
|
84
|
+
("config", lambda: self.config.restore(self._baseline_config)),
|
|
85
|
+
):
|
|
86
|
+
try:
|
|
87
|
+
step()
|
|
88
|
+
except Exception as error:
|
|
89
|
+
failures.append(f"{label}: {error}")
|
|
90
|
+
if failures:
|
|
91
|
+
raise TestkitError("reset() failed for " + "; ".join(failures))
|
|
92
|
+
|
|
93
|
+
def restart(self, *, timeout: float = 60.0) -> None:
|
|
94
|
+
handshake = self._process.restart(timeout=timeout)
|
|
95
|
+
self._rpc.close()
|
|
96
|
+
self._attach(
|
|
97
|
+
RpcClient.from_handshake(
|
|
98
|
+
handshake,
|
|
99
|
+
token=self._process.token,
|
|
100
|
+
timeout_scale=self._settings.timeout_scale,
|
|
101
|
+
)
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
def eval(self, source: str) -> Any:
|
|
105
|
+
if not self._settings.allow_eval:
|
|
106
|
+
raise TestkitError(
|
|
107
|
+
"nvda.eval() is disabled. It runs arbitrary code inside NVDA, so it is "
|
|
108
|
+
"opt-in: pass --nvda-allow-eval, or set allow-eval = true under "
|
|
109
|
+
"[tool.nvda-testkit]."
|
|
110
|
+
)
|
|
111
|
+
return self._rpc.call("eval_in_nvda", source)
|
|
112
|
+
|
|
113
|
+
def close(self) -> None:
|
|
114
|
+
self._rpc.close()
|
nvda_testkit/download.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Fetch NVDA launchers, verify them against nvaccess's published SHA-1, and
|
|
2
|
+
cache them on disk.
|
|
3
|
+
|
|
4
|
+
The digest check is not decoration: this code downloads an executable and then
|
|
5
|
+
runs it. A launcher whose digest does not match is deleted, not quarantined.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
from collections.abc import Callable
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from .errors import HashMismatchError
|
|
17
|
+
from .resolve import LauncherInfo, fetch_url
|
|
18
|
+
|
|
19
|
+
_UNSAFE = re.compile(r"[^A-Za-z0-9._-]")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def default_cache_dir() -> Path:
|
|
23
|
+
override = os.environ.get("NVDA_TESTKIT_CACHE")
|
|
24
|
+
if override:
|
|
25
|
+
return Path(override)
|
|
26
|
+
return Path.home() / ".cache" / "nvda-testkit"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def sha1_file(path: Path) -> str:
|
|
30
|
+
digest = hashlib.sha1() # NOSONAR -- nvaccess publishes SHA-1; matches NVDA's own updater
|
|
31
|
+
with path.open("rb") as handle:
|
|
32
|
+
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
|
33
|
+
digest.update(chunk)
|
|
34
|
+
return digest.hexdigest()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _cache_name(info: LauncherInfo) -> str:
|
|
38
|
+
stem = info.sha1 or f"{info.channel}-{info.version}"
|
|
39
|
+
return _UNSAFE.sub("_", stem) + ".exe"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def ensure_launcher(
|
|
43
|
+
info: LauncherInfo,
|
|
44
|
+
cache_dir: Path | None = None,
|
|
45
|
+
*,
|
|
46
|
+
fetch: Callable[[str], bytes] = fetch_url,
|
|
47
|
+
) -> Path:
|
|
48
|
+
"""Return a path to a launcher for `info`, downloading it if necessary."""
|
|
49
|
+
cache_dir = Path(cache_dir) if cache_dir is not None else default_cache_dir()
|
|
50
|
+
cache_dir.mkdir(parents=True, exist_ok=True)
|
|
51
|
+
target = cache_dir / _cache_name(info)
|
|
52
|
+
|
|
53
|
+
if target.exists():
|
|
54
|
+
if info.sha1 is None or sha1_file(target) == info.sha1:
|
|
55
|
+
return target
|
|
56
|
+
target.unlink()
|
|
57
|
+
|
|
58
|
+
partial = target.with_suffix(".part")
|
|
59
|
+
payload = fetch(info.url)
|
|
60
|
+
partial.write_bytes(payload)
|
|
61
|
+
|
|
62
|
+
actual = sha1_file(partial)
|
|
63
|
+
if info.sha1 is not None and actual != info.sha1:
|
|
64
|
+
partial.unlink()
|
|
65
|
+
raise HashMismatchError(expected=info.sha1, actual=actual, path=str(target))
|
|
66
|
+
|
|
67
|
+
os.replace(partial, target)
|
|
68
|
+
if info.sha1 is None:
|
|
69
|
+
# Nothing to check against, so record what we actually got. This ends up
|
|
70
|
+
# in the artifact bundle, which is the only way to tell after the fact
|
|
71
|
+
# which alpha build a failure came from.
|
|
72
|
+
target.with_suffix(".sha1").write_text(actual + "\n", encoding="ascii")
|
|
73
|
+
return target
|
nvda_testkit/errors.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Exception hierarchy. Every error the kit raises descends from TestkitError."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class TestkitError(Exception):
|
|
5
|
+
"""Base for every error raised by nvda-addon-testkit."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ProvisionError(TestkitError):
|
|
9
|
+
"""Failed to obtain or prepare an NVDA to test against."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class LauncherResolutionError(ProvisionError):
|
|
13
|
+
"""Could not work out which NVDA launcher to download."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class HashMismatchError(ProvisionError):
|
|
17
|
+
"""A downloaded launcher did not match its published digest."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, *, expected: str, actual: str, path: str) -> None:
|
|
20
|
+
self.expected = expected
|
|
21
|
+
self.actual = actual
|
|
22
|
+
self.path = path
|
|
23
|
+
super().__init__(
|
|
24
|
+
f"Launcher digest mismatch for {path}: expected {expected}, got {actual}. "
|
|
25
|
+
"Refusing to execute it."
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class UnsupportedPlatformError(TestkitError):
|
|
30
|
+
"""This operation needs a real Windows host."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class NvdaStartupError(TestkitError):
|
|
34
|
+
"""NVDA failed to start, or died before it was usable."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class HandshakeTimeout(NvdaStartupError):
|
|
38
|
+
"""NVDA started but the spy add-on never announced itself."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class RpcError(TestkitError):
|
|
42
|
+
"""A call into the spy add-on failed."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class AuthError(RpcError):
|
|
46
|
+
"""The spy rejected our token. Almost always a stale NVDA from a previous run."""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class WaitTimeout(TestkitError):
|
|
50
|
+
"""A deadline-bounded poll expired before its predicate came true."""
|
|
51
|
+
|
|
52
|
+
def __init__(self, description: str, timeout: float, last_seen: object = None) -> None:
|
|
53
|
+
self.description = description
|
|
54
|
+
self.timeout = timeout
|
|
55
|
+
self.last_seen = last_seen
|
|
56
|
+
message = f"Timed out after {timeout:.1f}s waiting for {description}."
|
|
57
|
+
if last_seen is not None:
|
|
58
|
+
message += f" Last seen: {last_seen!r}"
|
|
59
|
+
super().__init__(message)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Namespaces hung off NvdaClient: nvda.speech, nvda.braille, and so on."""
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""nvda.addons -- add-on install lifecycle.
|
|
2
|
+
|
|
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.
|
|
5
|
+
A test of installTasks.onInstall depends on being able to see both halves.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from enum import StrEnum
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from ..rpcclient import RpcClient
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AddonState(StrEnum):
|
|
18
|
+
NOT_INSTALLED = "NOT_INSTALLED"
|
|
19
|
+
PENDING_INSTALL = "PENDING_INSTALL"
|
|
20
|
+
ENABLED = "ENABLED"
|
|
21
|
+
DISABLED = "DISABLED"
|
|
22
|
+
PENDING_REMOVE = "PENDING_REMOVE"
|
|
23
|
+
INCOMPATIBLE = "INCOMPATIBLE"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class AddonInfo:
|
|
28
|
+
name: str
|
|
29
|
+
version: str
|
|
30
|
+
state: AddonState
|
|
31
|
+
|
|
32
|
+
@classmethod
|
|
33
|
+
def from_payload(cls, payload: dict) -> AddonInfo:
|
|
34
|
+
return cls(
|
|
35
|
+
name=payload["name"],
|
|
36
|
+
version=payload.get("version", "unknown"),
|
|
37
|
+
state=AddonState(payload.get("state", "NOT_INSTALLED")),
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class AddonsNamespace:
|
|
42
|
+
def __init__(self, rpc: RpcClient) -> None:
|
|
43
|
+
self._rpc = rpc
|
|
44
|
+
|
|
45
|
+
def list(self) -> list[AddonInfo]:
|
|
46
|
+
return [AddonInfo.from_payload(entry) for entry in self._rpc.call("addons_list")]
|
|
47
|
+
|
|
48
|
+
def state(self, name: str) -> AddonState:
|
|
49
|
+
return AddonState(self._rpc.call("addons_state", name))
|
|
50
|
+
|
|
51
|
+
def install(self, bundle_path: str | Path) -> AddonInfo:
|
|
52
|
+
"""Install a bundle. The add-on stays PENDING_INSTALL until a restart."""
|
|
53
|
+
payload = self._rpc.call("addons_install", str(Path(bundle_path).resolve()))
|
|
54
|
+
return AddonInfo.from_payload(payload)
|
|
55
|
+
|
|
56
|
+
def remove(self, name: str) -> None:
|
|
57
|
+
"""Mark for removal. Takes effect on the next restart."""
|
|
58
|
+
self._rpc.call("addons_remove", name)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""nvda.braille -- the raw text NVDA sent to the braille display."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
from ..rpcclient import RpcClient
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class BrailleNamespace:
|
|
11
|
+
def __init__(self, rpc: RpcClient) -> None:
|
|
12
|
+
self._rpc = rpc
|
|
13
|
+
|
|
14
|
+
def index(self) -> int:
|
|
15
|
+
return int(self._rpc.call("braille_index"))
|
|
16
|
+
|
|
17
|
+
def since(self, index: int) -> list[str]:
|
|
18
|
+
return [entry["text"] for entry in self._rpc.call("braille_since", index)]
|
|
19
|
+
|
|
20
|
+
def all(self) -> list[str]:
|
|
21
|
+
return self.since(0)
|
|
22
|
+
|
|
23
|
+
def last(self) -> str | None:
|
|
24
|
+
writes = self.all()
|
|
25
|
+
return writes[-1] if writes else None
|
|
26
|
+
|
|
27
|
+
def clear(self) -> None:
|
|
28
|
+
self._rpc.call("braille_clear")
|
|
29
|
+
|
|
30
|
+
def set_cell_count(self, count: int) -> None:
|
|
31
|
+
self._rpc.call("braille_set_cell_count", int(count))
|
|
32
|
+
|
|
33
|
+
def cell_count(self) -> int:
|
|
34
|
+
return int(self._rpc.call("braille_cell_count"))
|
|
35
|
+
|
|
36
|
+
def wait_for(
|
|
37
|
+
self,
|
|
38
|
+
pattern: str,
|
|
39
|
+
*,
|
|
40
|
+
timeout: float = 10.0,
|
|
41
|
+
since: int | None = None,
|
|
42
|
+
flags: int = re.IGNORECASE,
|
|
43
|
+
) -> str:
|
|
44
|
+
start = self.index() if since is None else since
|
|
45
|
+
|
|
46
|
+
def find():
|
|
47
|
+
for text in self.since(start):
|
|
48
|
+
if re.search(pattern, text, flags):
|
|
49
|
+
return text
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
return self._rpc.poll_until(
|
|
53
|
+
find,
|
|
54
|
+
timeout=timeout,
|
|
55
|
+
description=f"braille matching {pattern!r}",
|
|
56
|
+
last_seen=lambda: self.since(start),
|
|
57
|
+
)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""nvda.config -- read and write NVDA's configuration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from ..rpcclient import RpcClient
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _as_list(path) -> list[str]:
|
|
11
|
+
if isinstance(path, str):
|
|
12
|
+
return [path]
|
|
13
|
+
return list(path)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ConfigNamespace:
|
|
17
|
+
def __init__(self, rpc: RpcClient) -> None:
|
|
18
|
+
self._rpc = rpc
|
|
19
|
+
|
|
20
|
+
def get(self, path) -> Any:
|
|
21
|
+
return self._rpc.call("config_get", _as_list(path))
|
|
22
|
+
|
|
23
|
+
def set(self, path, value: Any) -> None:
|
|
24
|
+
self._rpc.call("config_set", _as_list(path), value)
|
|
25
|
+
|
|
26
|
+
def snapshot(self) -> dict:
|
|
27
|
+
return self._rpc.call("config_snapshot")
|
|
28
|
+
|
|
29
|
+
def restore(self, snapshot: dict) -> None:
|
|
30
|
+
self._rpc.call("config_restore", snapshot)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""nvda.keys -- send gestures through NVDA's own input pipeline.
|
|
2
|
+
|
|
3
|
+
press() returns only once NVDA has finished reacting, so a test never needs to
|
|
4
|
+
sleep between a keypress and the assertion about what it caused.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from ..rpcclient import RpcClient
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class KeysNamespace:
|
|
15
|
+
def __init__(self, rpc: RpcClient) -> None:
|
|
16
|
+
self._rpc = rpc
|
|
17
|
+
|
|
18
|
+
def press(self, gesture: str, *, timeout: float = 10.0) -> None:
|
|
19
|
+
self._rpc.call("keys_press", gesture, timeout)
|
|
20
|
+
|
|
21
|
+
def press_all(self, *gestures: str, timeout: float = 10.0) -> None:
|
|
22
|
+
for gesture in gestures:
|
|
23
|
+
self.press(gesture, timeout=timeout)
|
|
24
|
+
|
|
25
|
+
def type_text(self, text: str, *, timeout: float = 30.0) -> None:
|
|
26
|
+
self._rpc.call("keys_type", text, timeout)
|
|
27
|
+
|
|
28
|
+
def sent(self) -> list[dict[str, Any]]:
|
|
29
|
+
"""Every gesture sent this session. Goes into the replay trace."""
|
|
30
|
+
return list(self._rpc.call("keys_sent"))
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""nvda.log -- NVDA's log as structured records.
|
|
2
|
+
|
|
3
|
+
assert_no_errors quotes every offending record in its failure message. That
|
|
4
|
+
message is frequently the only diagnostic available to a maintainer reading a
|
|
5
|
+
CI log on a machine that cannot run NVDA at all.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
|
|
13
|
+
from ..rpcclient import RpcClient
|
|
14
|
+
|
|
15
|
+
_ERROR_LEVELS = frozenset({"ERROR", "CRITICAL"})
|
|
16
|
+
_WARNING_LEVELS = frozenset({"WARNING", "ERROR", "CRITICAL"})
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class LogRecord:
|
|
21
|
+
level: str
|
|
22
|
+
message: str
|
|
23
|
+
timestamp: float = 0.0
|
|
24
|
+
|
|
25
|
+
def __repr__(self) -> str:
|
|
26
|
+
return f"{self.level}: {self.message}"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _parse(payloads: list[dict]) -> list[LogRecord]:
|
|
30
|
+
return [
|
|
31
|
+
LogRecord(
|
|
32
|
+
level=str(payload.get("level", "")),
|
|
33
|
+
message=str(payload.get("message", "")),
|
|
34
|
+
timestamp=float(payload.get("timestamp") or 0.0),
|
|
35
|
+
)
|
|
36
|
+
for payload in payloads
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class LogNamespace:
|
|
41
|
+
def __init__(self, rpc: RpcClient) -> None:
|
|
42
|
+
self._rpc = rpc
|
|
43
|
+
|
|
44
|
+
def index(self) -> int:
|
|
45
|
+
return int(self._rpc.call("log_index"))
|
|
46
|
+
|
|
47
|
+
def since(self, index: int) -> list[LogRecord]:
|
|
48
|
+
return _parse(self._rpc.call("log_since", index))
|
|
49
|
+
|
|
50
|
+
def all(self) -> list[LogRecord]:
|
|
51
|
+
return self.since(0)
|
|
52
|
+
|
|
53
|
+
def clear(self) -> None:
|
|
54
|
+
self._rpc.call("log_clear")
|
|
55
|
+
|
|
56
|
+
def errors(self, *, since: int = 0) -> list[LogRecord]:
|
|
57
|
+
return [record for record in self.since(since) if record.level in _ERROR_LEVELS]
|
|
58
|
+
|
|
59
|
+
def warnings(self, *, since: int = 0) -> list[LogRecord]:
|
|
60
|
+
return [record for record in self.since(since) if record.level in _WARNING_LEVELS]
|
|
61
|
+
|
|
62
|
+
def _assert_clean(self, records: list[LogRecord], label: str) -> None:
|
|
63
|
+
if not records:
|
|
64
|
+
return
|
|
65
|
+
listing = "\n".join(f" {record}" for record in records)
|
|
66
|
+
raise AssertionError(f"NVDA logged {len(records)} {label}:\n{listing}")
|
|
67
|
+
|
|
68
|
+
def assert_no_errors(self, *, since: int = 0) -> None:
|
|
69
|
+
self._assert_clean(self.errors(since=since), "error(s)")
|
|
70
|
+
|
|
71
|
+
def assert_no_warnings(self, *, since: int = 0) -> None:
|
|
72
|
+
self._assert_clean(self.warnings(since=since), "warning(s) or error(s)")
|
|
73
|
+
|
|
74
|
+
def wait_for(
|
|
75
|
+
self,
|
|
76
|
+
pattern: str,
|
|
77
|
+
*,
|
|
78
|
+
timeout: float = 10.0,
|
|
79
|
+
since: int | None = None,
|
|
80
|
+
flags: int = re.IGNORECASE,
|
|
81
|
+
) -> LogRecord:
|
|
82
|
+
start = self.index() if since is None else since
|
|
83
|
+
|
|
84
|
+
def find():
|
|
85
|
+
for record in self.since(start):
|
|
86
|
+
if re.search(pattern, record.message, flags):
|
|
87
|
+
return record
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
return self._rpc.poll_until(
|
|
91
|
+
find,
|
|
92
|
+
timeout=timeout,
|
|
93
|
+
description=f"a log record matching {pattern!r}",
|
|
94
|
+
last_seen=lambda: [record.message for record in self.since(start)],
|
|
95
|
+
)
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""nvda.speech -- what NVDA asked to say."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ..rpcclient import RpcClient
|
|
6
|
+
from ..speechtypes import SpeechSequence, parse_sequences
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class SpeechNamespace:
|
|
10
|
+
def __init__(self, rpc: RpcClient) -> None:
|
|
11
|
+
self._rpc = rpc
|
|
12
|
+
|
|
13
|
+
def index(self) -> int:
|
|
14
|
+
"""The current cursor. Pass it to since() to ignore everything so far."""
|
|
15
|
+
return int(self._rpc.call("speech_index"))
|
|
16
|
+
|
|
17
|
+
def since(self, index: int) -> list[SpeechSequence]:
|
|
18
|
+
return parse_sequences(self._rpc.call("speech_since", index))
|
|
19
|
+
|
|
20
|
+
def all(self) -> list[SpeechSequence]:
|
|
21
|
+
return self.since(0)
|
|
22
|
+
|
|
23
|
+
def last(self) -> SpeechSequence | None:
|
|
24
|
+
sequences = self.all()
|
|
25
|
+
return sequences[-1] if sequences else None
|
|
26
|
+
|
|
27
|
+
def clear(self) -> None:
|
|
28
|
+
self._rpc.call("speech_clear")
|
|
29
|
+
|
|
30
|
+
def speak(self, text: str) -> None:
|
|
31
|
+
self._rpc.call("speech_speak", text)
|
|
32
|
+
|
|
33
|
+
def cancel(self) -> None:
|
|
34
|
+
self._rpc.call("speech_cancel")
|
|
35
|
+
|
|
36
|
+
def cancel_count(self) -> int:
|
|
37
|
+
return int(self._rpc.call("speech_cancel_count"))
|
|
38
|
+
|
|
39
|
+
def wait_for(
|
|
40
|
+
self,
|
|
41
|
+
pattern: str,
|
|
42
|
+
*,
|
|
43
|
+
timeout: float = 10.0,
|
|
44
|
+
since: int | None = None,
|
|
45
|
+
) -> SpeechSequence:
|
|
46
|
+
"""Wait for a sequence whose text matches `pattern` (regex, case-insensitive).
|
|
47
|
+
|
|
48
|
+
By default only speech produced after this call counts. Pass since=0 to
|
|
49
|
+
search everything captured so far.
|
|
50
|
+
|
|
51
|
+
Snapshots index() when called, so it cannot see speech already
|
|
52
|
+
emitted. After a synchronous action such as press() or speak(),
|
|
53
|
+
capture index() before the action and pass it as since.
|
|
54
|
+
"""
|
|
55
|
+
start = self.index() if since is None else since
|
|
56
|
+
|
|
57
|
+
def find():
|
|
58
|
+
for sequence in self.since(start):
|
|
59
|
+
if sequence.matches(pattern):
|
|
60
|
+
return sequence
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
return self._rpc.poll_until(
|
|
64
|
+
find,
|
|
65
|
+
timeout=timeout,
|
|
66
|
+
description=f"speech matching {pattern!r}",
|
|
67
|
+
last_seen=lambda: [sequence.text for sequence in self.since(start)],
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
def wait_for_done(self, *, timeout: float = 10.0) -> None:
|
|
71
|
+
"""Wait until NVDA's event queue has drained, i.e. speech has been dispatched."""
|
|
72
|
+
self._rpc.poll_until(
|
|
73
|
+
lambda: self._rpc.call("wait_until_idle", 5.0),
|
|
74
|
+
timeout=timeout,
|
|
75
|
+
description="NVDA to finish dispatching speech",
|
|
76
|
+
)
|