Files
MeshTerm/tests/test_diagnostics.py
T
Jean-Pierre MartineauandClaude Haiku 5.5 9e579424b4 The Anonymize preference masks the home folder and addresses in the log and diagnostics
Off by default. Each masked value keeps a tidbit (j*********u, DA:xx:xx:xx:7C:7C,
192.xxx.x.180, m******e.local), so two radios stay two radios. The log masks each
line as it is written; diagnostics take --anonymize/--no-anonymize, and the page
toggles with `a` (Shift+F3 on the PicoCalc). Refs #31.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-17 21:30:26 -04:00

725 lines
28 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""The Diagnostics block: what it says, what it does not say, and how it says it.
The privacy tests are the most important tests in this file. A user can paste this block
in public without a read of it. Thus "no secret is in it" must be a property of the
feature, not a habit of the person who last changed the code. Two tests hold this
property from different sides. One test puts real secrets in the stores that keep them,
and asserts that no secret is in the output. The other test refuses the names that such a
field can have. Thus a value that the suite cannot plant is still caught by the key that
it arrives under.
"""
from __future__ import annotations
import asyncio
import re
from pathlib import Path
import pytest
from rich.console import Console
from rich.text import Text
from meshterm.context import AppContext
from meshterm.core import hostinfo
from meshterm.core.admin_store import AdminStore
from meshterm.core.channel_store import ChannelStore
from meshterm.core.config import Settings
from meshterm.core.device_store import DeviceStore
from meshterm.core.models import Contact
from meshterm.core.preferences import Preferences
from meshterm.persistence.repository import Repository
from meshterm.tools.diagnostics import build_report, markdown_source
from meshterm.ui import fields
from meshterm.ui.about import AboutPage
from meshterm.ui.markdown import render_markdown
from meshterm.ui.renderers import (
JsonRenderer,
PlainRenderer,
facts_pairs,
markdown_blocks,
)
from meshterm.ui.report import Facts, Listing
#: The tests put these values in the stores that really keep this type of value. The block
#: must never have a value of this type. One value administers a repeater. One value
#: decrypts a channel.
_ADMIN_PASSWORD = "correct-horse-battery"
_CHANNEL_SECRET = "8f" * 16
#: The key segments that no field in this block can have. The test matches the parts of a
#: key between underscores, not substrings, so ``platform`` is not read as a latitude. The
#: first words name a secret. The next words name a person or a place. ``key`` and ``name``
#: complete the set, because the block does not identify a node, not even our node.
_FORBIDDEN_SEGMENTS = frozenset(
{
"password",
"passphrase",
"secret",
"private",
"privkey",
"pin",
"token",
"credential",
"contact",
"contacts",
"lat",
"latitude",
"lon",
"longitude",
"position",
"message",
"messages",
"key",
"name",
}
)
@pytest.fixture
def ctx(tmp_path: Path) -> AppContext:
"""A context on a temporary home, with a secret in each store that keeps secrets."""
settings = Settings(config_dir=tmp_path)
admin_store = AdminStore(tmp_path / "admin.json")
admin_store.remember(
Contact(name="Hilltop-Repeater", public_key="3d" * 32, key_prefix="3d63c642"),
_ADMIN_PASSWORD,
)
channel_store = ChannelStore(tmp_path / "channels.json")
channel_store.remember("ab" * 32, 1, "Lakeside", bytes.fromhex(_CHANNEL_SECRET))
return AppContext(
console=Console(),
settings=settings,
repo=Repository(settings.db_path),
device_store=DeviceStore(tmp_path / "device.json"),
admin_store=admin_store,
channel_store=channel_store,
preferences=Preferences(tmp_path / "preferences.toml", {"log_level": "DEBUG"}),
mock=True,
)
# --- what it must never say -----------------------------------------------------------
def test_no_planted_secret_reaches_either_face(ctx: AppContext) -> None:
"""A stored admin password and a channel secret are in neither face.
This is the purpose of the feature: a user can paste the block into a public issue
without a read of it first.
"""
report = _report(ctx)
for secret in (_ADMIN_PASSWORD, _CHANNEL_SECRET):
assert secret not in _plain(report)
assert secret not in _machine(report)
def test_no_field_is_named_after_a_secret_or_a_person(ctx: AppContext) -> None:
"""No key in the block looks like a credential, a position, or the identity of a person.
This is the lasting half of the guarantee. The suite cannot plant some values: a PIN
that the code reads live from a radio, or a private key that the code exports on
demand. Such a value must still arrive under a name. This test refuses each name that a
secret can have.
"""
for key in _keys(_report(ctx)):
offending = _FORBIDDEN_SEGMENTS & set(key.lower().split("_"))
assert not offending, f"{key!r} is named after {', '.join(sorted(offending))}"
def test_the_mesh_is_described_only_in_aggregate(ctx: AppContext) -> None:
"""The mesh arrives in the block as counts, never as a row that can name a person.
The block counts each table, and the count is an integer. If a listing starts to carry
a row from one of these tables, the name of a contact or the body of a message is one
edit away from the clipboard.
"""
tables = _flat(_report(ctx))["tables"]
assert tables, "the schema should always have tables to count"
assert all(set(row) == {"table", "rows"} for row in tables)
assert all(isinstance(row["rows"], int) for row in tables)
# --- what it must say -----------------------------------------------------------------
def test_states_the_facts_a_bug_report_opens_with(ctx: AppContext) -> None:
"""The block has the version, the install type, the host, the terminal, and the device."""
from meshterm import __version__
values = _flat(_report(ctx))
assert values["meshterm"] == __version__
assert values["install"] in {"frozen", "source", "package"}
assert values["os"] and values["python"] and values["arch"]
assert "x" in values["terminal_size"]
assert values["platform"] in {"regular", "picocalc-lyra"}
# The simulator answers as a radio does, so the device half has values and the test does
# not skip it. A real connected run produces this form.
assert values["connected"] is True
assert values["transport"] == "mock"
assert values["firmware"]
def test_an_unreachable_radio_becomes_a_fact_not_a_failure(
ctx: AppContext, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A radio that does not connect leaves the block complete, and the block says why.
The report about a radio that never connects is the report that is most important to
file. Thus the read of the device is best-effort: the failure goes in ``error``, and
each other fact still arrives.
"""
async def refuse(self):
raise RuntimeError("no companion answered on COM7")
monkeypatch.setattr(AppContext, "device", refuse)
values = _flat(_report(ctx))
assert values["connected"] is False
assert "COM7" in values["error"]
assert values["firmware"] is None
assert values["meshterm"] # the rest of the block has no change
def test_only_overridden_preferences_are_listed(ctx: AppContext) -> None:
"""The preference listing has the values that differ from the defaults, and only those.
If the block shows all forty preferences, it hides the one that the user changed. The
defaults are in the source, and they are the same for all users.
"""
assert _flat(_report(ctx))["preferences"] == [{"preference": "log_level", "value": "DEBUG"}]
# --- how it says it -------------------------------------------------------------------
def test_the_page_and_the_command_line_state_the_same_facts(ctx: AppContext) -> None:
"""Each key and value that the page draws is printed by the scripted face, in the same words.
The two faces share :func:`~meshterm.ui.renderers.facts_pairs`. Thus a field that
someone adds to the report arrives in both faces, and nobody must remember to add it
twice. This test finds a page that has a projection of its own.
"""
report = _report(ctx)
printed = _plain(report)
for block in report:
if isinstance(block, Facts):
for key, value in facts_pairs(block):
assert key in printed
assert value in printed
def test_the_page_is_a_written_page_with_landmarks(ctx: AppContext) -> None:
"""The page is markdown that the About-page renderer draws, and its sections are landmarks.
The sections are the purpose of the format. A heading pins to the top row while its own
rows scroll under it. The section jumps step by the headings. The PicoCalc lane has its
``Sect up``/``Sect down`` pair for the same reason as a grouped list.
"""
page = _page(ctx)
assert isinstance(page, AboutPage)
assert page.bare is False
assert {"Build", "Host", "Terminal", "Radio", "Storage"} <= set(page._doc.sections)
def test_the_save_is_advertised_on_both_platforms(ctx: AppContext) -> None:
"""The verb is in the footer hint and in the one free slot of the lane.
The verb is not in the body of the page, because the body is the report. The verb is
also not missing from both places. A bare frame forced this problem in the past. F1 and
F2 are the section pair, and F4 and F5 are the pager. Thus F3 is the slot that the About
pages leave free.
"""
from meshterm.ui.diagnostics import SAVE_ACTION, SAVE_KEY
page = _page(ctx)
assert f"{SAVE_KEY} save" in page.footer_hint
assert page.footer_hint.endswith("Esc back")
assert page.picocalc_lyra_lane[2] is not None
assert page.picocalc_lyra_lane[2].label == "Save"
assert page.picocalc_lyra_lane[2].action == SAVE_ACTION
# --- saving it ------------------------------------------------------------------------
def test_the_saved_file_is_the_document_the_page_shows(ctx: AppContext, tmp_path: Path) -> None:
"""The file has the same markdown that the page was built from: one source, two places."""
from meshterm.tools.diagnostics import markdown_source, write_markdown
source = markdown_source(_report(ctx))
written = write_markdown(source, tmp_path / "out.md")
assert written.read_text(encoding="utf-8") == source
assert source.startswith("# MeshTerm diagnostics")
def test_saving_creates_the_directory_it_needs(ctx: AppContext, tmp_path: Path) -> None:
"""If the parent of the destination does not exist, the save makes it. It does not refuse."""
from meshterm.tools.diagnostics import markdown_source, write_markdown
written = write_markdown(markdown_source(_report(ctx)), tmp_path / "a" / "b" / "out.md")
assert written.is_file()
def test_the_key_and_the_chip_both_save_and_answer_in_a_popup(
ctx: AppContext, tmp_path: Path
) -> None:
"""The key and the chip each write the file and show the path in an acknowledgement dialog.
The chip sends the same action as the key. Thus it is not a second implementation of the
same verb.
"""
from meshterm.ui.diagnostics import SAVE_ACTION, SAVE_KEY
for trigger, target in ((("text", SAVE_KEY), "key.md"), ((SAVE_ACTION, ""), "chip.md")):
path = tmp_path / target
session = _Session()
page = _page(ctx, session=session, save_path=path)
page.handle(*trigger)
assert path.is_file()
assert len(session.messages) == 1
assert "\u2713" in session.messages[0]
assert str(path) in session.messages[0]
def test_a_save_that_fails_is_reported_not_raised(ctx: AppContext, tmp_path: Path) -> None:
"""If the destination cannot be written, the page reports it and stays readable.
The user can still read the page and copy the text from it. This is the fallback that
is important, so a failed write must never close the page. The mark also makes the
dialog red.
"""
from meshterm.ui.diagnostics import SAVE_KEY
# A path whose "directory" is an existing file: mkdir and write both fail, on each OS,
# and the test does not need permissions that the suite cannot rely on.
blocker = tmp_path / "blocker"
blocker.write_text("not a directory", encoding="utf-8")
session = _Session()
page = _page(ctx, session=session, save_path=blocker / "inside" / "out.md")
page.handle("text", SAVE_KEY)
assert "\u2717" in session.messages[0]
assert "could not save" in session.messages[0]
assert "Build" in _body(page) # the page itself has no change
def test_the_popup_carries_its_tone_as_spans_not_as_markup(ctx: AppContext, tmp_path: Path) -> None:
"""The message is a styled Text, so the dialog prints no tag and the border is correct.
If the message is a markup string, the dialog does not parse it. The user sees the
literal tags, and a failure has the border in the colour of success, because the
dialog chooses the border from the spans of the message. The mark carries the tone, and
nothing else is necessary.
"""
from meshterm.ui.diagnostics import SAVE_KEY
from meshterm.ui.tui.session import _message_border
for target, expected in ((tmp_path / "ok.md", "border"), (_blocked(tmp_path), "err")):
session = _Session(keep=True)
page = _page(ctx, session=session, save_path=target)
page.handle("text", SAVE_KEY)
message = session.shown[0]
assert isinstance(message, Text)
assert "[/" not in message.plain and "[ok]" not in message.plain
assert _message_border(message) == expected
def test_a_held_save_key_does_not_stack_popups(ctx: AppContext, tmp_path: Path) -> None:
"""The page does one save at a time. It ignores a second press while a save is in progress."""
from meshterm.ui.diagnostics import SAVE_KEY
session = _Session(defer=True)
page = _page(ctx, session=session, save_path=tmp_path / "out.md")
page.handle("text", SAVE_KEY)
page.handle("text", SAVE_KEY)
assert len(session.started) == 1
def test_another_letter_falls_through_to_the_pager(ctx: AppContext, tmp_path: Path) -> None:
"""Each other letter is for the scroll handler, not for the save."""
session = _Session()
page = _page(ctx, session=session, save_path=tmp_path / "out.md")
page.handle("text", "q")
assert not (tmp_path / "out.md").exists()
assert session.messages == []
# --- the markdown face ------------------------------------------------------------------
#: A value that has markdown in all its parts: backslashes that markdown reads as escapes,
#: and asterisks and underscores that it reads as emphasis. Markdown must not reinterpret
#: any of it.
_HOSTILE = r"C:\Users\*jp*\_meshterm_"
def test_every_value_survives_markdown_untouched() -> None:
"""Values are code spans, so the backslashes and stars of a path arrive without a change.
A path, or an error message that the firmware of another person wrote, is not markdown.
The renderer must never read it as markdown without a warning. If a value has a
backtick, the renderer uses a longer fence.
"""
source = markdown_blocks(
(
Facts(
key="x",
caption="X",
fields=(fields.word("p", "p"), fields.word("q", "q")),
values={"p": _HOSTILE, "q": "a `backtick` inside"},
),
)
)
drawn = _render(render_markdown(source))
assert _HOSTILE in drawn
assert "a `backtick` inside" in drawn
def test_a_caption_becomes_the_section_heading() -> None:
"""The caption of a block is its ``##``. A block with no caption has no heading."""
assert markdown_blocks((Facts(key="x", caption="Radio", fields=()),)).startswith("## Radio")
assert not markdown_blocks((Facts(key="x", fields=()),)).startswith("#")
def test_an_empty_section_says_so_rather_than_vanishing() -> None:
"""An absence is a fact here: "no preferences changed" is information that the user can use.
The plain face removes an empty listing, because a record stream has nothing to say
about one. A document does have something to say. If a section is missing with no
note, the user asks if the code ever built it.
"""
empty = Listing(key="preferences", caption="Changed preferences", columns=(), rows=[])
assert markdown_blocks((empty,)).endswith("*none*")
assert "none" in _render(render_markdown(markdown_blocks((empty,))))
# --- the host probe -------------------------------------------------------------------
@pytest.mark.parametrize(
"environ,expected",
[
({"WT_SESSION": "abc", "TERM": "xterm"}, "Windows Terminal"),
({"TERM_PROGRAM": "Apple_Terminal", "TERM": "xterm-256color"}, "Terminal.app"),
({"TERM_PROGRAM": "vscode"}, "VS Code"),
# A terminal that this module does not know still names itself, because the value
# of TERM_PROGRAM is the name. This is the reason for this rung.
({"TERM_PROGRAM": "WezTerm"}, "WezTerm"),
({"TERM": "linux"}, "linux"),
({}, None),
],
)
def test_the_terminal_is_identified_from_what_it_says_about_itself(
environ: dict, expected: str | None
) -> None:
"""Each rung of the ladder works, to the last ``None`` when nothing names a terminal."""
assert hostinfo.terminal_program(environ) == expected
def test_an_inner_terminal_wins_over_the_one_hosting_it() -> None:
"""A marker variable has priority over ``TERM_PROGRAM``. The inner emulator is the answer.
The terminal of VS Code gets the environment of the outer terminal and adds its own
marker. If the report names the outer terminal, it names a terminal that draws nothing.
"""
env = {"WT_SESSION": "abc", "TERM_PROGRAM": "vscode"}
assert hostinfo.terminal_program(env) == "Windows Terminal"
def test_ssh_is_read_from_either_variable() -> None:
"""The block states a connection over ssh, because it changes what the code can know."""
assert hostinfo.terminal({"SSH_TTY": "/dev/pts/0"}).over_ssh is True
assert hostinfo.terminal({"SSH_CONNECTION": "10.0.0.2 51000 10.0.0.1 22"}).over_ssh is True
assert hostinfo.terminal({}).over_ssh is False
def test_the_host_block_is_populated_on_whatever_runs_the_suite() -> None:
"""Each host field has an answer, on each OS where CI runs this test."""
machine = hostinfo.host()
assert machine.install in {"frozen", "source", "package"}
assert machine.os and machine.arch and machine.python
assert machine.python.count(".") == 2
# --- the repository aggregates --------------------------------------------------------
def test_table_counts_are_discovered_from_the_schema(tmp_path: Path) -> None:
"""The code counts each table, but not the internal tables of SQLite.
The code reads the table names from ``sqlite_master``. Thus a table that someone adds
to the schema is in the report on the day that it is added. A list that people write
becomes old without a warning, and the count that is important is always for the table
that nobody expected to be full.
"""
counts = Repository(tmp_path / "meshterm.db").table_counts()
assert {"runs", "observations", "messages", "traces"} <= set(counts)
assert not any(name.startswith("sqlite_") for name in counts)
assert all(isinstance(n, int) for n in counts.values())
def test_the_observation_span_is_absent_until_something_is_heard(tmp_path: Path) -> None:
"""An empty database has no span. It does not have a span of nothing."""
repo = Repository(tmp_path / "meshterm.db")
assert repo.observation_span() == (None, None)
assert repo.failed_run_count() == 0
# --- anonymized -------------------------------------------------------------------------
#: A Bluetooth address, in the words of a radio that failed to connect.
_RADIO = "DA:C5:21:6B:7C:7C"
@pytest.fixture
def home(ctx: AppContext, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
"""Put the config directory in a home folder with an account name, on each OS.
The radio refuses with its Bluetooth address in the error, so that each type of
personal detail is in the report.
"""
from meshterm.core import anonymize
folder = tmp_path / "jpmartineau"
monkeypatch.setattr(anonymize, "_home", lambda: str(folder))
ctx.settings.config_dir = folder / ".meshterm"
async def refuse(self):
raise RuntimeError(f"BLE link to {_RADIO} failed to open")
monkeypatch.setattr(AppContext, "device", refuse)
return folder
def test_the_report_keeps_each_detail_by_default(ctx: AppContext, home: Path) -> None:
"""With the preference off, the report is as it was: the owner's own facts, unmasked."""
values = _flat(_report(ctx))
assert values["anonymized"] is False
assert "jpmartineau" in str(values["config_dir"])
assert _RADIO in values["error"]
def test_the_preference_masks_the_home_folder_and_the_radio(ctx: AppContext, home: Path) -> None:
"""With the preference on, both faces mask each detail, and keep a tidbit of it."""
ctx.preferences.set("anonymize", True)
report = _report(ctx)
values = _flat(report)
assert values["anonymized"] is True
assert values["config_dir"].name == ".meshterm"
assert "j*********u" in str(values["config_dir"])
assert "DA:xx:xx:xx:7C:7C" in values["error"]
for face in (_plain(report), _machine(report), markdown_source(report)):
assert "jpmartineau" not in face
assert _RADIO not in face
def test_an_override_wins_over_the_preference(ctx: AppContext, home: Path) -> None:
"""``--anonymize`` and ``--no-anonymize`` decide for one run, in each direction."""
assert _flat(asyncio.run(build_report(ctx, anonymize=True)))["anonymized"] is True
ctx.preferences.set("anonymize", True)
assert _flat(asyncio.run(build_report(ctx, anonymize=False)))["anonymized"] is False
def test_the_command_line_takes_the_override(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""The flag pair is on the command, and the preference decides when neither is given."""
import json
from typer.testing import CliRunner
from meshterm.cli import app
monkeypatch.setenv("MESHTERM_HOME", str(tmp_path / "home"))
def anonymized(*args: str) -> bool:
result = CliRunner().invoke(app, ["--mock", "--json", "diagnostics", *args])
assert result.exit_code == 0, result.output
return json.loads(result.stdout)["anonymized"]
assert anonymized() is False
assert anonymized("--anonymize") is True
set_it = CliRunner().invoke(app, ["preferences", "set", "anonymize", "on"])
assert set_it.exit_code == 0, set_it.output
assert anonymized() is True
assert anonymized("--no-anonymize") is False
def test_the_page_key_changes_the_form_and_the_save_writes_it(
ctx: AppContext, home: Path, tmp_path: Path
) -> None:
"""The key shows the other form, the title and the hint say which, and Save writes it."""
from meshterm.ui.diagnostics import ANONYMIZE_KEY, SAVE_KEY
path = tmp_path / "out.md"
page = _page(ctx, save_path=path, anonymizable=True)
assert page.title == "Diagnostics"
assert f"{ANONYMIZE_KEY} anonymize" in page.footer_hint
page.handle("text", ANONYMIZE_KEY)
assert page.title == "Diagnostics · anonymized"
assert f"{ANONYMIZE_KEY} reveal" in page.footer_hint
assert "jpmartineau" not in _body(page)
page.handle("text", SAVE_KEY)
saved = path.read_text(encoding="utf-8")
assert "j*********u" in saved and "jpmartineau" not in saved
page.handle("text", ANONYMIZE_KEY)
assert page.title == "Diagnostics"
# The document and not the drawn lines: a long temporary path can wrap in the name.
assert "jpmartineau" in page._source
def test_the_page_opens_in_the_form_of_the_preference(ctx: AppContext, home: Path) -> None:
"""The preference chooses the first form, and the chip on the lane names the other one."""
from meshterm.ui.diagnostics import ANONYMIZE_ACTION
page = _page(ctx, anonymizable=True, anonymized=True)
assert page.title == "Diagnostics · anonymized"
chip = page.picocalc_lyra_lane[2]
assert (chip.label, chip.opp_label, chip.opp_action) == ("Save", "Reveal", ANONYMIZE_ACTION)
page.handle(ANONYMIZE_ACTION)
assert page.picocalc_lyra_lane[2].opp_label == "Anon"
# --- helpers ---------------------------------------------------------------------------
def _report(ctx: AppContext):
"""Build the report, in the same way as both faces."""
return asyncio.run(build_report(ctx))
def _flat(report) -> dict:
"""Each fact in the report, flattened in the same way as the JSON face."""
flat: dict = {}
for block in report:
if isinstance(block, Facts):
flat.update(block.values)
else:
flat[block.key] = list(block.rows)
return flat
def _keys(report) -> list[str]:
"""Each key that the block declares, for the facts and for the columns of the listings."""
keys: list[str] = []
for block in report:
if isinstance(block, Facts):
keys.extend(column.key for column in block.fields)
else:
keys.extend(column.key for column in block.columns)
return keys
def _plain(report) -> str:
"""The whole block, as the plain face writes it."""
console = _console(200)
with console.capture() as captured:
PlainRenderer(console).render(report)
return captured.get()
def _machine(report) -> str:
"""The document of the machine face, as text."""
console = _console(10_000)
with console.capture() as captured:
JsonRenderer(console).render(report)
return captured.get()
def _render(renderable) -> str:
"""A renderable as plain text, at the standard width."""
console = _console(72)
with console.capture() as captured:
console.print(renderable)
return captured.get()
def _console(width: int) -> Console:
"""A capture console that adds nothing between the content and the string."""
return Console(width=width, color_system=None, highlight=False, markup=False)
def _blocked(tmp_path: Path) -> Path:
"""A destination that no OS accepts: a "directory" that is a file.
Both the mkdir and the write fail, on each OS, and the test does not need permissions
that the suite cannot rely on.
"""
blocker = tmp_path / "blocker"
if not blocker.exists():
blocker.write_text("not a directory", encoding="utf-8")
return blocker / "inside" / "out.md"
def _strip(line: str) -> str:
"""One rendered line without its SGR sequences."""
return re.sub(r"\x1b\[[0-9;]*m", "", line)
class _Session:
"""A replacement for a session. It runs the save flow inline and records the dialog.
In the real session, ``run_detached`` gives the flow of a key handler to the event loop.
Here it runs the flow at once. Thus a test can press a key and assert on what the user
sees. If ``defer`` is set, the class keeps the coroutine and does not run it. One test
needs this for a save that is still in progress.
"""
def __init__(self, *, defer: bool = False, keep: bool = False) -> None:
"""Start with nothing shown and nothing started."""
self.messages: list[str] = []
self.shown: list[object] = []
self.started: list[object] = []
self._defer = defer
self._keep = keep
def invalidate(self) -> None:
"""A request for a paint. A test has no screen to paint."""
def run_detached(self, work):
"""Run the flow now. If ``defer`` is set, keep the flow and do not run it."""
self.started.append(work)
if self._defer:
work.close()
return None
return asyncio.run(work)
async def message_dialog(self, message, *, title: str = "") -> None:
"""Record the text that the acknowledgement dialog shows."""
self.messages.append(str(message))
if self._keep:
self.shown.append(message)
def _page(
ctx: AppContext,
*,
session: _Session | None = None,
save_path: Path | None = None,
anonymizable: bool = False,
anonymized: bool = False,
):
"""The page for the report of this context. It saves where the caller says.
With ``anonymizable``, the page also gets the anonymized form, as the tool gives it.
"""
from meshterm.tools.diagnostics import anonymized as masked
from meshterm.ui.diagnostics import DiagnosticsPage
target = save_path or (ctx.settings.config_dir / "meshterm-diagnostics.md")
report = asyncio.run(build_report(ctx, anonymize=False))
return DiagnosticsPage(
markdown_source(report),
session=session or _Session(),
save_path=target,
anonymized_source=markdown_source(masked(report)) if anonymizable else None,
anonymized=anonymized,
)
def _body(page) -> str:
"""The rows that the page draws, as plain text."""
return "\n".join(_strip(line) for line in page.render_body(100))