# SPDX-License-Identifier: Apache-2.0 """Shared fixtures and helpers: plain-text screen reading, fixed path rendering, project files. :func:`plain` is the only way that a test reads a rendered screen. Each screen test must first remove the ANSI colour escapes and join the rendered lines. Only then can it make assertions about the content. If each file did this again, the copies would differ over time. The powerline verdict depends on the environment. The suite can run inside VS Code or Windows Terminal (both can draw chips) or in a bare CI shell (which cannot). The screen assertions must not change with the terminal of the developer. ``MESHTERM_POWERLINE=0`` is the supported way to fix the verdict. The suite clears the cached verdict around each test, so that the order of the tests has no effect. The tests that are specific to powerline pass explicit modes, or they patch the widget's own switch with ``monkeypatch``. ``NO_COLOR`` depends on the environment in the same way. Rich obeys it: it removes colour and keeps attributes. This silently makes each colour assertion in the suite useless (a shell that an agent harness runs sets it). The autouse fixture below deletes it. Thus the tests always see the colours that the app really sends. :func:`not_ignored` keeps a test that walks a folder to the files of the project. Git ignores the other files in the checkout, and these files are not ours to examine. """ from __future__ import annotations import os import re import subprocess from collections.abc import Callable, Iterable, Iterator from pathlib import Path import pytest from meshterm.platforms import REGULAR, set_platform from meshterm.ui.termfont import powerline_support #: ANSI SGR escapes: the colour runs that a rendered line has between its characters. _ANSI = re.compile(r"\x1b\[[0-9;]*m") def plain(rendered: str | Iterable[str]) -> str: """The plain-text form of rendered screen output, for content assertions. The function takes the result of a render: a list of ANSI lines (which it joins with newlines), or one string that is already joined. It removes the colour escapes. Thus the assertions read the screen in the same way as a viewer. """ if not isinstance(rendered, str): rendered = "\n".join(rendered) return _ANSI.sub("", rendered) def recording_locator(markers: list) -> object: """A real :class:`~meshterm.ui.map_screen.Locator` whose map is a list, ``shown``. It finds markers with the rule of the app. Instead of a map, :meth:`show` appends the marker that the map would open on to ``shown``. """ from meshterm.ui.map_screen import Locator class _Recording(Locator): async def show(self, marker) -> None: # noqa: ANN001 - a MapMarker self.shown.append(marker) locator = _Recording(None, markers) # type: ignore[arg-type] locator.shown = [] # type: ignore[attr-defined] return locator def recording_pages(contacts: list, *, store: object = None, self_key: str = "") -> object: """A real :class:`~meshterm.ui.node_detail_screen.ContactPages` whose pages are a list. It finds contacts with the rule of the app. Instead of a node page, :meth:`show` appends the contact that the page would open on to ``shown`` (``None`` is our node). With a contact ``store`` and the key of the device, ^K can lock the contacts too. """ from types import SimpleNamespace from meshterm.ui.node_detail_screen import ContactPages class _Recording(ContactPages): async def show(self, contact) -> None: # noqa: ANN001 - a Contact, or None for us self.shown.append(contact) ctx = None if store is None else SimpleNamespace(contact_store=store) pages = _Recording(ctx, contacts, self_key=self_key) # type: ignore[arg-type] pages.shown = [] # type: ignore[attr-defined] return pages #: The root of the checkout. _ROOT = Path(__file__).resolve().parent.parent def not_ignored(paths: Iterable[Path]) -> list[Path]: """The paths in ``paths`` that git does not ignore, in their order. A test that walks a folder of the checkout must examine only the files of the project. A folder can also hold files that git ignores. An example is the MeshCore checkout that ``build-firmware.sh`` makes in ``scripts/``, with its third-party code and its build tree. Git never ignores a tracked file. When git is not available (for example, in an unpacked source archive), the function returns all the paths, and the test examines each file, as it did before. """ paths = list(paths) relative = [path.resolve().relative_to(_ROOT).as_posix() for path in paths] try: result = subprocess.run( ["git", "check-ignore", "--stdin", "-z"], cwd=_ROOT, input="\0".join(relative), capture_output=True, encoding="utf-8", check=False, ) except OSError: return paths if result.returncode not in (0, 1): # 1 is "nothing ignored", 128 is "not a repository" return paths ignored = set(result.stdout.split("\0")) return [path for path, name in zip(paths, relative, strict=True) if name not in ignored] @pytest.fixture(autouse=True) def _plain_path_rendering(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]: monkeypatch.setenv("MESHTERM_POWERLINE", "0") # An inherited NO_COLOR makes Rich remove colour (and keep attributes). Then each # colour assertion silently reads bare text. Refer to the module docstring. monkeypatch.delenv("NO_COLOR", raising=False) powerline_support.cache_clear() yield powerline_support.cache_clear() @pytest.fixture def powerline(monkeypatch: pytest.MonkeyPatch) -> Callable[[bool], None]: """Fix the powerline verdict for a test that is about chips. The suite runs with ``MESHTERM_POWERLINE=0``. Thus the screen assertions do not change with the terminal of the developer (refer to the module docstring). A test of the chip language needs the other answer. The test asks for it through the supported override, and does not go into the widget. ``monkeypatch`` restores the environment, and the autouse fixture above clears the cached verdict before and after. Thus no state stays. """ def _set(on: bool) -> None: monkeypatch.setenv("MESHTERM_POWERLINE", "1" if on else "0") powerline_support.cache_clear() return _set @pytest.fixture(autouse=True) def _reset_transmit_gate() -> Iterator[None]: """Each test starts with nothing transmitted. The transmit clock is for the whole process (refer to :mod:`meshterm.core.transmit_gate`). This is correct for a session and wrong for a suite. If a test sent anything, it would leave a cooldown that is still active for the next test. Then the flow of the next test would stop on a countdown that nobody wrote in the script. """ from meshterm.core.transmit_gate import current as transmit_clock transmit_clock().reset() yield transmit_clock().reset() @pytest.fixture(autouse=True) def _isolate_config_dir(tmp_path_factory: pytest.TempPathFactory) -> Iterator[None]: """No test can reach the developer's own ``~/.meshterm``. ``$MESHTERM_HOME`` moves the whole directory: the history, the caches of contacts and channels, the outbox, the stored admin passwords, the device profiles, the preferences, and the log. ``--db`` moves only the first of these. Thus a CLI test that used only ``--db`` was not isolated. A synthetic contact that a probe wrote stayed in the real cache. This made ``test_cli_contract`` fail on one machine and pass on each other machine. Also, each run added lines to the real ``meshterm.log`` of the developer, and read the real ``log_level``. The fixture is autouse and unconditional, because the risk is not only in the CLI. Any code that uses ``Settings.load()``, a preference, a store, or the logger has it. A test that needs a directory with data sets the variable itself. ``monkeypatch.setenv`` in a test still wins over this fixture. """ home = tmp_path_factory.mktemp("meshterm-home") previous = os.environ.get("MESHTERM_HOME") os.environ["MESHTERM_HOME"] = str(home) yield if previous is None: os.environ.pop("MESHTERM_HOME", None) else: os.environ["MESHTERM_HOME"] = previous @pytest.fixture(autouse=True) def _empty_registries(monkeypatch: pytest.MonkeyPatch) -> None: """Each test starts with no registered secret and no registered host. The two registries are process-wide on purpose: a secret that MeshTerm read once must stay hidden for the rest of the run. In a suite, that means that a password one test used ("hello", the default room password) hides the same word in each later test that looks for it. ``monkeypatch`` puts the shared objects back after the test. """ from meshterm.core import anonymize, secrets monkeypatch.setattr(secrets, "_known", set()) monkeypatch.setattr(secrets, "_known_pattern", None) monkeypatch.setattr(anonymize, "_hosts", {}) monkeypatch.setattr(anonymize, "_host_pattern", None) @pytest.fixture(autouse=True) def _reset_platform() -> Iterator[None]: """Each test starts and ends on :data:`~meshterm.platforms.REGULAR`. A test can call ``set_platform(PICOCALC_LYRA)`` (directly, or through the gallery harness). That choice cannot stay for the next test. Regular is the baseline of the suite. It is also the default for a process that does not get ``--platform`` or ``MESHTERM_PLATFORM`` (refer to :mod:`meshterm.platforms`). """ set_platform(REGULAR) yield set_platform(REGULAR)