diff --git a/CHANGELOG.md b/CHANGELOG.md index 837fa4df..616ab471 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -133,11 +133,35 @@ The format follows [Keep a Changelog](https://keepachangelog.com/); versions fol ### Fixed +- **The window opens wider, and the lists on the Tools tab show every column whole.** At 760 + pixels the TCP health list lost its last column in every language, and words such as + "jeszcze bez pomiaru" (TCP health) and "Ustawienia proxy" (Adapters) were cut. The window + now opens 960 pixels wide at 100% and cannot be made narrower. Each column fits the longest + word it can show, and an empty list's note wraps inside it. A window size saved by an older + version grows to the new width and stays on its screen. + +- **Linux, experimental: the texts say what Linux does.** The Sockets note says that Linux + shows the programs of other accounts only to root, and that a connection that is closing, + or a socket held from outside the system, as in WSL, may have no program at all. A socket + in TIME_WAIT shows PID 0, as on Windows. The process field, its "?" help and `--help` show + `chrome`, which finds the program on Windows and on Linux. TCP health on a kernel without + the socket list says so. + +- **Linux, experimental: Copy CLI command gives a command Linux can run.** It is the command + the README starts the program with: `sudo .venv/bin/python -B bean_network_tester.py`, + without sudo for `--simulate`. Its values are in single quotes, so bash keeps a `!`, a `$` + or a backslash in them as it is. + +- **Texts that were not true on every system.** The process field's tip no longer says it + requires psutil, which comes with the program and which Windows does not use for it. On a + system other than Windows and Linux, `--doctor` and the Tools tab say that capture and the + tools work on Windows and Linux. + - **The names of the tools on the Tools tab are whole.** On Linux in Polish, and in every language but Chinese once the window was made narrow, each name lost its last letters, such as "Zdrowie TCI" for "Zdrowie TCP". The tabs inside a page are now narrower than the page tabs above them, so the seven tools fit in one row. The main window can no longer be made - narrower than it opens, on any screen and at any display scaling (760 pixels at 100%). + narrower than it opens, on any screen and at any display scaling. - **The log no longer says a page opened when no browser started.** After a click on **Donate** the log said the support page was opened even when the browser did not start. It now says the diff --git a/README.md b/README.md index f46ce80b..31952f1e 100644 --- a/README.md +++ b/README.md @@ -497,7 +497,8 @@ program says so in the log when you apply it, and each of the two counters repor **Target process** - narrow the effect to chosen apps: process name (e.g. `chrome.exe`), PID, a comma-separated list, PID range, wildcard or regular expression - see [Filter syntax](#filter-syntax-process--ip--port). The rest of the machine's traffic stays -untouched. Empty field = all traffic. +untouched. Empty field = all traffic. On Linux a process name has no `.exe` (`chrome`, +`firefox`), and a bare name such as `chrome` finds the program on both systems. **Speed limit** - maximum throughput separately for download (inbound) and upload (outbound), in KB/s. 0 = no limit. Ping is small packets, so a speed limit barely changes it - to test the limit @@ -934,7 +935,10 @@ Designed so that after a bug you can recreate exactly the same conditions: says which queue produced its numbers. The same three values go into the log at START. - **Copy CLI command** - straight to the clipboard: `BeanNetworkTester.exe --seed ... --loss ... --duration ...` (the command adapts to the build: from the repository you get - `python bean_network_tester.py ...`). + `python bean_network_tester.py ...`, and on Linux the way this README starts it, + `sudo .venv/bin/python -B bean_network_tester.py ...`, with no `sudo` and no `-B` for + `--simulate`. A virtual environment outside the checkout is named from `~`, and any other + interpreter as `python3`, so the command carries no path from your machine). - **What the report and the command describe** - the last session, not the form: the settings START gave it, updated by every "Apply changes", with its seed and its scenario (`--scenario`, plus `--loop` if it looped). A change you typed but did not apply is left out, @@ -1062,8 +1066,9 @@ connections arriving from outside, are still blocked in silence. > In `cmd.exe`/PowerShell **quote the expression** if it contains a comma, `!`, `>`, `<`, `*` or > `^` - otherwise the shell interprets it its own way (`cmd.exe` drops a bare `^`, so `re:^edge` -> would arrive as `re:edge`). The command that recreates the session -> (`Copy CLI command` and the `Reproduce:` line) quotes them for you. +> would arrive as `re:edge`). In bash use single quotes: inside double quotes bash still reads +> `$`, a backtick and `!` its own way. The command that recreates the session +> (`Copy CLI command` and the `Reproduce:` line) quotes them for you, for the shell of the system. **Run and reporting** diff --git a/beantester/appinfo.py b/beantester/appinfo.py index 447df6f9..50943370 100644 --- a/beantester/appinfo.py +++ b/beantester/appinfo.py @@ -8,6 +8,7 @@ drift. ``tests/test_repo_conventions.py`` fails the build if a copy appears. """ import re +import sys APP_NAME = "Bean Network Tester" # human-readable window / product name AUTHOR = "DonislawDev" # shown in the GUI, the exe metadata and --version @@ -50,14 +51,65 @@ def _read_version(): __version__ = _read_version() -def command_name(): - """How the user invokes this build: the exe when frozen, the script otherwise. +def command_name(simulate=False): + """How the user invokes this build: the exe when frozen, the script otherwise - + on Linux the way its README starts it (``linux_command``). Reproduction commands and ``--help`` must be copy-paste ready, and a frozen - build has no ``python bean_network_tester.py`` to offer. + build has no ``python bean_network_tester.py`` to offer. ``simulate``: the + command runs ``--simulate``, which on Linux needs no root. """ from .paths import is_frozen - return EXE_NAME if is_frozen() else f"python {LAUNCHER}" + if is_frozen(): + return EXE_NAME + if sys.platform.startswith("linux"): + return linux_command(simulate) + return f"python {LAUNCHER}" + + +def linux_command(simulate=False, executable=None, checkout=None, home=None): + """The script as Linux runs it from this checkout, the README's way: + ``sudo .venv/bin/python -B bean_network_tester.py``. + + MEASURED 2026-10-27 on Ubuntu 26.04: there is no ``python`` there, only + ``python3``, for a user or under sudo, so the command the window copied ended + in "command not found"; a capture needs root, so without ``sudo`` it ended with + exit code 7; and pydivert lives in the virtual environment, which the system's + ``python3`` is not. So ``sudo`` and ``-B`` (root leaves no cache folders in the + checkout) unless the command is a simulation, and the interpreter running now, + named with no path from this machine - a report carrying the command is shared: + relative to the checkout when it is inside it, from ``~`` when it is in the + person's home, else ``python3``. + """ + from . import paths + executable = sys.executable if executable is None else executable + checkout = paths.PROJECT_ROOT if checkout is None else checkout + home = _persons_home() if home is None else home + interpreter = (_inside(executable, checkout, "") + or (home and _inside(executable, home, "~/")) or "python3") + return " ".join(([] if simulate else ["sudo"]) + [interpreter] + + ([] if simulate else ["-B"]) + [LAUNCHER]) + + +def _inside(path, base, prefix): + """``path`` as ``prefix`` + its place under ``base``, or None when it is not under it.""" + import posixpath + import shlex + if not path or not base: + return None + place = posixpath.relpath(posixpath.abspath(path), posixpath.abspath(base)) + if place == posixpath.pardir or place.startswith(posixpath.pardir + "/"): + return None + return prefix + shlex.quote(place) + + +def _persons_home(): + """The home of the person running the program: under sudo theirs, not root's.""" + import os + + from . import paths + person = paths.invoking_user() + return paths.home_of(person[0]) if person else os.path.expanduser("~") def version_line(): diff --git a/beantester/capabilities.py b/beantester/capabilities.py index fbe0b09d..0ad64b00 100644 --- a/beantester/capabilities.py +++ b/beantester/capabilities.py @@ -27,14 +27,15 @@ loads a Windows DLL is either named here or listed there with the reason it gates itself. -``reason`` is an i18n key: Windows only for now. A tool ported to Linux may still be +``reason`` is an i18n key: the shared words, which name the systems the tools work +on (Windows and Linux). A tool ported to Linux may still be missing on one system or another, and then its module says why in its own words (``why_not()``, asked when it is there): Ping, the first one, needs the ping socket for the user's group or sudo (``icmp.why_not``). Reset on Linux needs a kernel built with ``CONFIG_INET_DIAG_DESTROY`` (WSL's is not); it has no words of its own, because its only reader is a row menu, which greys the entry out - the README says why. TCP health on Linux needs a kernel that lists TCP sockets through sock_diag (every one -measured does); without it the shared words are said. +measured does); without it the module says so (``tcpestats.why_not``). """ from types import ModuleType from typing import NamedTuple, Optional diff --git a/beantester/capture_backend.py b/beantester/capture_backend.py index fdf79b94..11a91ba5 100644 --- a/beantester/capture_backend.py +++ b/beantester/capture_backend.py @@ -86,7 +86,7 @@ class WinDivertBackend: # Off Windows (a platform with no capture driver) there is no WinDivert service. CLEANUP_LINE = "Not Windows - there is no WinDivert driver to clean up." CLEANUP_BLOCKER = "tools.diagnostics.clean_windows_only" - # ...and no rows of its own to explain: its --doctor rows say capture needs Windows. + # ...and no rows of its own to explain: its --doctor rows say capture needs Windows or Linux. DIAGNOSTICS_HELP: Tuple[str, ...] = () @staticmethod diff --git a/beantester/cli.py b/beantester/cli.py index 6d007f2d..8aafd8d2 100644 --- a/beantester/cli.py +++ b/beantester/cli.py @@ -114,7 +114,7 @@ def _read_scenario(path, log): %(prog)s --simulate --loss 20 --duration 10 try it out. No driver, no real traffic, nothing to break. - %(prog)s --target chrome.exe --latency 200 --duration 30 + %(prog)s --target chrome --latency 200 --duration 30 impair one application for half a minute, and nothing else. %(prog)s --dst-ip 10.0.0.5 --dst-port 443 --loss 5 --duration 60 @@ -142,7 +142,7 @@ def _add_scope_arguments(p): p.add_argument("--target", help="target processes: name/PID, comma-separated list, range, " "wildcard, re: pattern, ! to exclude " - "(e.g. 'chrome.exe,!chromedriver' or 're:^fire')") + "(e.g. 'chrome,!chromedriver' or 're:^fire')") p.add_argument("--dst-ip", help="affect only traffic to/from these remote IPs, IPv4 and IPv6: " "address, list, range a-b, CIDR, wildcard, comparison, re: " @@ -1364,7 +1364,7 @@ def _window_did_not_open(exc): in place of stderr, so a double-clicked exe just vanished with exit code 1. """ if isinstance(exc, ImportError) and exc.name in _TK_MODULES: - text = f"No tkinter. Use CLI mode, e.g.: {command_name()} --simulate --loss 5" + text = f"No tkinter. Use CLI mode, e.g.: {command_name(simulate=True)} --simulate --loss 5" else: text = f"The window could not open: {type(exc).__name__}: {exc}" if crashlog.record(exc, source="gui-start") is not None: diff --git a/beantester/driver.py b/beantester/driver.py index f70ce933..9a07b72b 100644 --- a/beantester/driver.py +++ b/beantester/driver.py @@ -771,7 +771,7 @@ def doctor(): ("platform", "ok" if is_windows() else "warn", f"{platform.system()} {platform.release()}" + ("" if is_windows() else - note if note is not None else " - capture needs Windows; use --simulate")), + note if note is not None else " - capture needs Windows or Linux; use --simulate")), ("frozen", "ok", "yes" if getattr(sys, "frozen", False) else "no"), _clock_check()] diff --git a/beantester/gui/app.py b/beantester/gui/app.py index 7571726b..b0d2f0c1 100644 --- a/beantester/gui/app.py +++ b/beantester/gui/app.py @@ -52,8 +52,8 @@ from .profiles import ProfileStore from .rates import SessionPeaks from . import scope -from .scaling import (geometry_fits, init_scaling, initial_geometry, - max_window_size, min_window_size, scaled) +from .scaling import (init_scaling, max_window_size, min_window_size, restored_geometry, + scaled) from .scrollable import WheelDispatcher from .theme import (BG, FIELD, FG, MONO_FONT, MUT, apply_dark_titlebar, disable_maximize, init_style, popdown_height, @@ -266,15 +266,12 @@ def _restore_geometry(self): except Exception: screen_w, screen_h = 1366, 768 maximum = max_window_size(screen_w, screen_h) - saved = str(self.ui.get("geometry", "") or "") - if saved and geometry_fits(saved, screen_w, screen_h, winenv.monitor_work_area): - geometry = saved - else: - w, h, x, y = initial_geometry(screen_w, screen_h) - geometry = f"{w}x{h}+{x}+{y}" + minimum = min_window_size(screen_w=screen_w, screen_h=screen_h) + geometry = restored_geometry(str(self.ui.get("geometry", "") or ""), minimum, + screen_w, screen_h, winenv.monitor_work_area) try: root.geometry(geometry) - root.minsize(*min_window_size(screen_w=screen_w, screen_h=screen_h)) + root.minsize(*minimum) # a capped size AND no maximise button: the layout has an upper bound # at which it still looks like a layout, so do not offer more root.maxsize(*maximum) diff --git a/beantester/gui/scaling.py b/beantester/gui/scaling.py index 7efe3c32..b7790040 100644 --- a/beantester/gui/scaling.py +++ b/beantester/gui/scaling.py @@ -24,8 +24,13 @@ # measured, and it showed: at the old 620 px minimum the Tools sub-tabs were cut in # every language but Chinese, Windows included (2026-10-26). Width is what cuts - a # row of tabs or buttons cannot wrap - while a shorter window scrolls, so the -# minimum HEIGHT stays below the first-run one. -FIRST_RUN = (760, 920) +# minimum HEIGHT stays below the first-run one. 960 since 2026-10-27, 760 before: at +# 760 the list of TCP health had a 691 px table for six columns that wanted 699 to +# 716 (real Tk, Windows and Linux), so its last column was cut in every language, and +# with whole words and the longest IPv4 address and port they want 858 - the table +# is 891 at 960 (rendered, Linux's widest font). 960 also fits a 1366 px screen at +# 125 %; at 150 % that screen caps it, as it always capped a wider window. +FIRST_RUN = (960, 920) MIN_H = 560 _scale = 1.0 @@ -95,6 +100,18 @@ def column_width(header, min_chars=0, padding=22): return max(text_width(header, padding), scaled(min_chars * 7 + padding)) +# A treeview cell shows its text whole from about 4 px wider than the text at 100 % +# (6 at 150 %) - MEASURED 2026-10-27 on real Tk, the clam theme: the text starts that +# far in, and nothing is kept on its right. 8 leaves room for a glyph whose ink +# reaches past its advance. +CELL_PAD = 8 + + +def cell_width(text, font=None): + """Width a table column needs to show ``text`` whole in one of its cells.""" + return text_width(text, CELL_PAD, font) + + def initial_geometry(screen_w, screen_h, want=FIRST_RUN, scale=None): """Startup window geometry, clamped to the screen and centred. @@ -183,12 +200,69 @@ def geometry_fits(geometry, screen_w, screen_h, bounds_at=None): size lost, not a window lost, and it buys the case that matters: a window on a monitor LARGER than the primary one, which the old check rejected outright. """ + parsed = _parse_geometry(geometry) + if parsed is None: + return False + w, h, x, y = parsed + left, top, right, bottom = _monitor_of(x, y, screen_w, screen_h, bounds_at) + if w < 320 or h < 320 or w > right - left or h > bottom - top: + return False + # allow a little off-screen slack, but the title bar must stay reachable + return left - 20 <= x <= right - 100 and top <= y <= bottom - 60 + + +def restored_geometry(saved, minimum, screen_w, screen_h, bounds_at=None): + """The ``WxH+X+Y`` the main window opens with: the one saved, grown to + ``minimum`` (``grown_to``) while it still describes a reachable window + (``geometry_fits``), else the first-run size, centred. + + The saved geometry is judged BEFORE it grows as well. Growing moves the corner + onto the monitor nearest to it, so a narrow window saved on a monitor that has + since been unplugged came back pulled onto the edge of the one left, instead of + in the centre of the screen.""" + if geometry_fits(saved, screen_w, screen_h, bounds_at): + grown = grown_to(saved, *minimum, screen_w, screen_h, bounds_at) + if geometry_fits(grown, screen_w, screen_h, bounds_at): + return grown + w, h, x, y = initial_geometry(screen_w, screen_h) + return f"{w}x{h}+{x}+{y}" + + +def grown_to(geometry, min_w, min_h, screen_w, screen_h, bounds_at=None): + """A saved ``WxH+X+Y`` no smaller than the window's minimum, still on its monitor. + + Tk grows a window restored below its ``minsize`` by itself (measured 2026-10-27 + on Windows and Linux: a saved 760x920 under a 960 minimum opens 960 wide), but it + grows it to the RIGHT, so a window an older version saved against the right edge + of its monitor opened with 200 px past that edge. The size is grown here instead, + and the corner moved left (or up) by as much as the growth would push the window + past its monitor, never past the monitor's left (top) edge. A window that already + has the minimum, and anything that is not a geometry, come back unchanged: + ``geometry_fits`` judges them as it always did. + """ + parsed = _parse_geometry(geometry) + if parsed is None: + return geometry + w, h, x, y = parsed + if w >= min_w and h >= min_h: + return geometry + left, top, right, bottom = _monitor_of(x, y, screen_w, screen_h, bounds_at) + w, h = max(w, int(min_w)), max(h, int(min_h)) + if x + w > right: + x = max(left, right - w) + if y + h > bottom: + y = max(top, bottom - h) + return f"{w}x{h}+{x}+{y}" + + +def _parse_geometry(geometry): + """``(w, h, x, y)`` of a ``WxH+X+Y`` string, or ``None`` for anything else.""" try: size, _, rest = str(geometry).partition("+") w_s, _, h_s = size.partition("x") w, h = int(w_s), int(h_s) except (TypeError, ValueError): - return False + return None parts = rest.split("+") try: # Tk writes a negative coordinate as "+-1882", so splitting on "+" leaves @@ -196,16 +270,18 @@ def geometry_fits(geometry, screen_w, screen_h, bounds_at=None): # round trip through ui.json. x, y = (int(parts[0]), int(parts[1])) if len(parts) >= 2 else (0, 0) except ValueError: - return False - left, top, right, bottom = 0, 0, int(screen_w), int(screen_h) + return None + return w, h, x, y + + +def _monitor_of(x, y, screen_w, screen_h, bounds_at): + """The work area a window's corner at ``(x, y)`` is judged by: the monitor + ``bounds_at`` names for it, else the primary screen.""" if bounds_at is not None: area = bounds_at(x, y) if area is not None: - left, top, right, bottom = (int(edge) for edge in area) - if w < 320 or h < 320 or w > right - left or h > bottom - top: - return False - # allow a little off-screen slack, but the title bar must stay reachable - return left - 20 <= x <= right - 100 and top <= y <= bottom - 60 + return tuple(int(edge) for edge in area) + return 0, 0, int(screen_w), int(screen_h) def centred_in(bounds, width, height): diff --git a/beantester/gui/theme.py b/beantester/gui/theme.py index cdbef556..e5e80725 100644 --- a/beantester/gui/theme.py +++ b/beantester/gui/theme.py @@ -197,10 +197,14 @@ # The adapters tool: `adapter` fits most adapter names as Windows gives them (8 to # 34 characters on the development machine, measured 2026-10-13 - a longer one is # cut in the list and shown whole in the details below it); `if_status` and -# `if_kind` the longest status and type word in the three languages; `internet` +# `if_kind` are floors - the list sizes those columns by the words they can show +# (``widgets.make_room``), which a count of characters had missed; `internet` # fits "IPv4, IPv6"; `item` the longest row name of the details table; `note` an # address's origin and its duplicate-address warning side by side. TCP health: -# `rtt` fits "1234 ms", `resent` the column's header in the three languages. Ping: +# `rtt` fits "1234 ms" (its words size it past that), `resent` the column's header in +# the three languages, `endpoint` an IPv4 address with its port, the longest one +# ("255.255.255.255:65535") whole in the widest font measured - DejaVu Sans, 152 +# px at 100 % (2026-10-27). Ping: # `host` is the port check's 24 - an address or a name, a longer one scrolls in the # box; `sent_at` fits "12:01:07" and the column's header; `result` its longest # word in the three languages ("Odpowiedź wróciła zmieniona"); `seq` five digits. @@ -216,6 +220,7 @@ "proto": 5, "process": 16, "tcp_state": 12, + "endpoint": 21, "verdict": 23, "adapter": 24, "if_status": 14, @@ -248,7 +253,7 @@ # Sizes that are neither a gap nor a run of characters, in design pixels at 100 %. # `side_chart`: the TCP health RTT chart beside its details table - room for the # axis numbers, "-60 s" and "now", while the details keep three readable columns in -# the 760 px window a 1366x768 screen opens (rendered 2026-10-19 in en/pl/zh: about +# the 760 px window a 1366x768 screen opened then (rendered 2026-10-19 in en/pl/zh: about # six detail rows, nothing cut but the table's own sideways scroll). Stacked under # the details instead, the chart left them ONE pixel. `chart`: the least height of # a chart's canvas. diff --git a/beantester/gui/toolbox/adapters.py b/beantester/gui/toolbox/adapters.py index da1ce789..46b25819 100644 --- a/beantester/gui/toolbox/adapters.py +++ b/beantester/gui/toolbox/adapters.py @@ -39,7 +39,7 @@ from ..clipboard import copy_confirmed from ..theme import ADAPTER_COLORS, CHARS, ROWS, space, style_menu from ..tooltip import add_tooltip -from ..widgets import SortableTree +from ..widgets import SortableTree, make_room from .base import Poller, StatusLine, job, remembered from .details import DetailsMenu, popup, render_detail, say @@ -57,6 +57,12 @@ "kind": CHARS["if_kind"], "ipv4": CHARS["address"], "internet": CHARS["internet"]} +PROXY_KIND = adapters.Words("tools.adapters.kind.proxy") +# The words the list can say: each column is born as wide as the widest of them, in +# the window's language (``widgets.make_room``) - the proxy row's among them. +WORDS = {"status": (*adapters.vocabulary(adapters.STATUS, adapters.STATUS_PREFIX), *proxy.STATES), + "kind": (*adapters.vocabulary(adapters.KINDS, adapters.KIND_PREFIX), PROXY_KIND)} + DETAIL_COLUMNS = {"item": "tools.adapters.col.item", "value": "tools.adapters.col.value", "note": "tools.adapters.col.note"} DETAIL_TIPS = {column: f"tips.tools_adapters_col_{column}" for column in DETAIL_COLUMNS} @@ -106,7 +112,7 @@ def render(adapter): """One adapter's cells, in ``COLUMNS`` order.""" if isinstance(adapter, ProxyEntry): return (T("tools.adapters.proxy.row"), say(proxy.state_words(adapter.reading)), - T("tools.adapters.kind.proxy"), proxy.first_server(adapter.reading), "") + say(PROXY_KIND), proxy.first_server(adapter.reading), "") row = adapter.row return (row.name, say(adapters.status_words(row.status)), say(adapters.kind_words(row.kind)), adapter.first_ipv4(), @@ -153,6 +159,7 @@ def __init__(self, app, parent): top, COLUMNS, sort=self._sort("sort"), on_sort=self._on_sort, height=ROWS["adapters"], min_chars=MIN_CHARS, tips=tips(), tags=ADAPTER_COLORS, selectmode="browse", empty_text="tools.common.working") + make_room(self.table, {column: [say(w) for w in said] for column, said in WORDS.items()}) self.table.bind_select(self._on_select) bottom = ttk.Frame(self.frame) bottom.pack(fill="both", expand=True, padx=space("page"), diff --git a/beantester/gui/toolbox/diagnostics.py b/beantester/gui/toolbox/diagnostics.py index 382d3815..ed5ac8df 100644 --- a/beantester/gui/toolbox/diagnostics.py +++ b/beantester/gui/toolbox/diagnostics.py @@ -120,7 +120,7 @@ def __init__(self, app, parent): # report goes into a PUBLIC bug report and these carry this machine's # addresses, MAC and proxy (nettools/diagnostics.py, REPORT_SECTIONS). On a # line of their own: with the second box the Polish bar outgrew the 760 px - # window a 1366x768 screen opens and pushed the help button out (rendered + # window a 1366x768 screen opened then and pushed the help button out (rendered # 2026-10-14). boxes = ttk.Frame(self.frame) boxes.pack(fill="x", padx=space("page"), pady=(space("tight"), 0)) diff --git a/beantester/gui/toolbox/ping.py b/beantester/gui/toolbox/ping.py index 1b4240b6..928a0060 100644 --- a/beantester/gui/toolbox/ping.py +++ b/beantester/gui/toolbox/ping.py @@ -369,7 +369,7 @@ def __init__(self, app, parent): self.status = StatusLine(self.frame) self.status.label.pack(fill="x", padx=space("page"), pady=(space("row"), 0)) - # Wrapped inside both page margins: a Polish summary is longer than the 760 px + # Wrapped inside both page margins: a Polish summary can be longer than the # window (``PAGE_WRAP_PAD``). self.session = wrapping_label(self.frame, "", pad=PAGE_WRAP_PAD) self.session.pack(fill="x", padx=space("page"), pady=(space("hair"), 0)) @@ -397,7 +397,7 @@ def __init__(self, app, parent): # 71 px tall at 760x678 (rendered). The tip and the help say what it is. add_tooltip(self.canvas, "tips.tools_ping_chart") # Sideways scrolling: a router's answer ("TTL expired on the way (from ...)") - # is longer than the Result column at 760 px; widened, it scrolls instead of + # is longer than the Result column; widened, it scrolls instead of # squeezing the numbers. The copied results always carry it whole. self.table = SortableTree( body, COLUMNS, sort={"col": "", "reverse": False}, on_sort=lambda _s: self._show(), diff --git a/beantester/gui/toolbox/sockets.py b/beantester/gui/toolbox/sockets.py index fa2fa187..efbb2542 100644 --- a/beantester/gui/toolbox/sockets.py +++ b/beantester/gui/toolbox/sockets.py @@ -292,7 +292,7 @@ def _notes(self, snapshot, stale=False): lines.append(T("tools.sockets.note_failed", tables=", ".join(snapshot.failed))) unnamed = sockets.without_owner(snapshot) if unnamed: - lines.append(T("tools.sockets.note_no_pid", count=unnamed)) + lines.append(T(sockets.no_owner_note(), count=unnamed)) return lines def _show_failure(self): diff --git a/beantester/gui/toolbox/tcphealth.py b/beantester/gui/toolbox/tcphealth.py index 3d96713c..a77886da 100644 --- a/beantester/gui/toolbox/tcphealth.py +++ b/beantester/gui/toolbox/tcphealth.py @@ -36,7 +36,7 @@ from ..labels import wrapping_label from ..theme import CHARS, HEALTH_COLORS, ROWS, size, space from ..tooltip import add_tooltip -from ..widgets import SortableTree +from ..widgets import SortableTree, make_room from .base import Poller, StatusLine, job, remembered from .details import DetailsMenu, render_detail, say @@ -57,9 +57,12 @@ LEGEND = (("Swatch.Down.TLabel", "tools.tcphealth.legend_smoothed"), ("Swatch.Up.TLabel", "tools.tcphealth.legend_sample")) LEGEND_LINUX = LEGEND[:1] -MIN_CHARS = {"process": CHARS["process"], "local": CHARS["address"], - "remote": CHARS["address"], "state": CHARS["tcp_state"], "rtt": CHARS["rtt"], +MIN_CHARS = {"process": CHARS["process"], "local": CHARS["endpoint"], + "remote": CHARS["endpoint"], "state": CHARS["tcp_state"], "rtt": CHARS["rtt"], "resent": CHARS["resent"]} +# The words the list can say beside numbers and addresses: each column is born as +# wide as the widest of them, in the window's language (``widgets.make_room``). +WORDS = {"state": tcphealth.STATE_VOCABULARY, "rtt": (tcphealth.Words(tcphealth.NO_RTT),)} DETAIL_COLUMNS = {"item": "tools.tcphealth.col.item", "value": "tools.tcphealth.col.value", "note": "tools.tcphealth.col.note"} DETAIL_TIPS = {column: f"tips.tools_tcphealth_col_{column}" for column in DETAIL_COLUMNS} @@ -168,6 +171,7 @@ def __init__(self, app, parent): top, COLUMNS, sort={"col": "", "reverse": False}, on_sort=lambda _s: self._show(), height=ROWS["watched"], min_chars=MIN_CHARS, tips=tips(), tags=HEALTH_COLORS, selectmode="browse", empty_text="tools.tcphealth.empty") + make_room(self.table, {column: [say(w) for w in said] for column, said in WORDS.items()}) self.table.bind_select(self._on_select) bottom = ttk.Frame(self.frame) diff --git a/beantester/gui/widgets/__init__.py b/beantester/gui/widgets/__init__.py index 51dc1eca..00aeff77 100644 --- a/beantester/gui/widgets/__init__.py +++ b/beantester/gui/widgets/__init__.py @@ -1,4 +1,4 @@ """Reusable GUI widgets.""" -from .sortable_tree import SortableTree +from .sortable_tree import SortableTree, make_room -__all__ = ["SortableTree"] +__all__ = ["SortableTree", "make_room"] diff --git a/beantester/gui/widgets/sortable_tree.py b/beantester/gui/widgets/sortable_tree.py index c8eaf9bb..f0aa92c2 100644 --- a/beantester/gui/widgets/sortable_tree.py +++ b/beantester/gui/widgets/sortable_tree.py @@ -55,10 +55,11 @@ at a glance). """ import sys +import tkinter.font as tkfont from tkinter import TclError, ttk from ...i18n import T -from ..scaling import column_width, scaled +from ..scaling import cell_width, column_width, scaled from ..tooltip import make_bubble from ..wheel import wheel_units from ... import crashlog @@ -175,9 +176,39 @@ def rows_shown_in_full(first_box, width, height, region_at): BUFFER_ROWS = 4 # slots past the viewport: a partial row + resize slack +# How much of the table's width the empty note may take before it wraps. Unwrapped, +# TCP health's Polish note was 905 px in an 891 px table on Linux and lost its first +# and last letters to the frame (real Tk, 2026-10-27). +NOTE_SHARE = 0.9 MIN_WINDOW = 12 # slots to keep even before the widget has a size +def _cell_font(): + """The font the rows are drawn in, to measure a column's words with, or ``None`` + (``scaling``'s measuring font) where the style names none.""" + name = ttk.Style().lookup("Treeview", "font") + return tkfont.Font(font=name) if name else None + + +def make_room(table, words): + """Widen each column of ``words`` (column id -> the texts it can show besides + numbers and names, in the window's language) in ``table`` to fit the widest of + them, in the rows' own font - as its natural width, so a fit and a reset keep it. + + Asked right after the table is built, before anything is fitted. A hand-counted + ``min_chars`` went stale the first time a word grew: Linux's "Ustawienia proxy" + and Windows' "jeszcze bez pomiaru" were both cut (real Tk, 2026-10-27). A + function of this module rather than a method: the class is near its ceiling + of methods (``tests/test_code_shape.py``). + """ + font = _cell_font() + for col, texts in words.items(): + need = max((cell_width(text, font) for text in texts), default=0) + if need > table._natural.get(col, 0): + table._natural[col] = need + table.tree.column(col, width=need) + + class SortableTree: """Wraps a virtualised ``ttk.Treeview`` + scrollbars in ``parent``.""" @@ -281,7 +312,7 @@ def __init__(self, parent, columns, sort=None, on_sort=None, # old language on a table that happened to be empty at the time. self._empty_text = empty_text self._empty_note = ttk.Label(self.frame, text="", style="Hint.TLabel", - anchor="center") if empty_text else None + anchor="center", justify="center") if empty_text else None self.refresh_headers() self._ensure_slots(self._height + BUFFER_ROWS) self._bind_events() @@ -428,6 +459,8 @@ def _on_configure(self, _=None): if resized: self.offset = min(self.offset, self.max_offset()) self.repaint() + if self._empty_note is not None: # the note wraps inside the table + self._empty_note.config(wraplength=int(self.tree.winfo_width() * NOTE_SHARE)) # A wider widget means new slack to absorb. Guarded by the memo, so this # fires once per real size change and not on every event. self.fit_columns() diff --git a/beantester/nettools/adapters.py b/beantester/nettools/adapters.py index 77ee2f26..f7f99a77 100644 --- a/beantester/nettools/adapters.py +++ b/beantester/nettools/adapters.py @@ -55,17 +55,31 @@ class Words(NamedTuple): code: int | str | None = None +STATUS_PREFIX, KIND_PREFIX = "tools.adapters.status.", "tools.adapters.kind." +# The widest number a word sized for its column shows in place of a name: a state, +# a type or a refusal's code no table here names (IfType and errno fit in three +# digits, a Windows error code in five). +WIDEST_CODE = 99999 + + def _word(table, prefix, number): slug = table.get(number) return Words(prefix + slug) if slug else Words(prefix + "code", number) def status_words(number): - return _word(STATUS, "tools.adapters.status.", number) + return _word(STATUS, STATUS_PREFIX, number) def kind_words(number): - return _word(KINDS, "tools.adapters.kind.", number) + return _word(KINDS, KIND_PREFIX, number) + + +def vocabulary(table, prefix): + """Every word ``_word`` can say from ``table``: one per name, and the one for a + number the table does not name, at ``WIDEST_CODE`` - what a list column that + shows them is sized for (``widgets.make_room``).""" + return (*(Words(prefix + slug) for slug in table.values()), Words(prefix + "code", WIDEST_CODE)) class Adapter(NamedTuple): diff --git a/beantester/nettools/proxy.py b/beantester/nettools/proxy.py index 3aa8dad2..29ce2edb 100644 --- a/beantester/nettools/proxy.py +++ b/beantester/nettools/proxy.py @@ -114,12 +114,18 @@ def unreadable(reading): # -- the row on the adapter list ---------------------------------------------------- # +IN_USE, UNREADABLE, NOT_SET = (Words("tools.adapters.proxy.state.in_use"), + Words("tools.adapters.proxy.state.unreadable"), + Words("tools.adapters.proxy.state.not_set")) +STATES = (IN_USE, UNREADABLE, NOT_SET) # what its column is sized for + + def state_words(reading): if in_use(reading): - return Words("tools.adapters.proxy.state.in_use") + return IN_USE if unreadable(reading): - return Words("tools.adapters.proxy.state.unreadable") - return Words("tools.adapters.proxy.state.not_set") + return UNREADABLE + return NOT_SET def first_server(reading): diff --git a/beantester/nettools/sockets.py b/beantester/nettools/sockets.py index 79375db9..db2a679f 100644 --- a/beantester/nettools/sockets.py +++ b/beantester/nettools/sockets.py @@ -14,10 +14,11 @@ """ import ipaddress import itertools +import sys import time from typing import NamedTuple -from .. import portmap +from .. import paths, portmap from ..matchers import KIND_INT, KIND_IP, KIND_PROCESS from ..views import compile_query @@ -125,8 +126,29 @@ def _sockets(rows, names): row.pid, name) +NO_OWNER, NO_OWNER_LINUX, NO_OWNER_ROOT = ("tools.sockets.note_no_pid", + "tools.sockets.note_no_pid_linux", + "tools.sockets.note_no_pid_root") + + +def no_owner_note(platform=None): + """What the note on sockets without a program says here. Windows names their + owner to an administrator. Linux names the program only to root - measured + 2026-10-27 on Ubuntu 26.04: 14 of 14 sockets without one as a user, none under + sudo - and the ones still without one under sudo are held from outside this + system (WSL: its DNS on the tunnel address and a second chrony socket, for which + ``ss`` as root names no process either) or by no process any more: a closing + connection that its program has already closed. TIME_WAIT is PID 0, so not + counted (``portmap._psutil_row``); FIN_WAIT_2 and LAST_ACK cannot be told from + their state, so both Linux texts say "may".""" + if not (sys.platform if platform is None else platform).startswith("linux"): + return NO_OWNER + return NO_OWNER_ROOT if paths.running_as_root() else NO_OWNER_LINUX + + def without_owner(snapshot): - """How many sockets the system would not name a process for (another account's).""" + """How many sockets the system would not name a process for (another account's, + and on Linux also one held from outside it or by no process any more).""" return sum(1 for socket in snapshot.sockets if socket.pid is None) diff --git a/beantester/nettools/tcphealth.py b/beantester/nettools/tcphealth.py index da5e035d..9801ed86 100644 --- a/beantester/nettools/tcphealth.py +++ b/beantester/nettools/tcphealth.py @@ -21,7 +21,7 @@ from typing import NamedTuple from .. import crashlog, driver, portmap, tcpestats, tcprow -from .adapters import Detail, Words +from .adapters import WIDEST_CODE, Detail, Words HISTORY = 60 # chart points: a minute at one reading a second MAX_WATCHED = 20 # each costs two calls a second, ~50 us (measured) @@ -297,6 +297,12 @@ def _find(table, target): tcpestats.FAILED: "tools.tcphealth.state.failed"} +# Every word the list's state column can say, a refusal at its widest code: what the +# column is sized for (``widgets.make_room``). +STATE_VOCABULARY = tuple(Words(key, WIDEST_CODE) for key in STATE_WORDS.values()) +NO_RTT = "tools.tcphealth.value.no_rtt" + + def state_words(watch): return Words(STATE_WORDS.get(watch.state, "tools.tcphealth.state.failed"), watch.code) @@ -309,7 +315,7 @@ def rtt_number(value): def ms(value): """A round trip: ``214 ms``, ``< 1 ms``, or ``Words`` for "not measured yet".""" - return Words("tools.tcphealth.value.no_rtt") if value is None else f"{rtt_number(value)} ms" + return Words(NO_RTT) if value is None else f"{rtt_number(value)} ms" def process_text(watch): @@ -382,7 +388,7 @@ def details(watch): ms(last.sample_rtt))), (("min_rtt", "max_rtt"), Detail( "range", Words("tools.tcphealth.item.range"), - Words("tools.tcphealth.value.no_rtt") + Words(NO_RTT) if last.min_rtt is None or last.max_rtt is None else f"{rtt_number(last.min_rtt)} / {rtt_number(last.max_rtt)} ms")), # A stack without the highest round trip (Linux) says the lowest alone. diff --git a/beantester/paths.py b/beantester/paths.py index 0d753aca..8a0c6caf 100644 --- a/beantester/paths.py +++ b/beantester/paths.py @@ -284,10 +284,10 @@ def temp_beside(path): One more consequence, decided rather than overlooked: on POSIX ``mkstemp`` creates the file 0600, and ``os.replace`` carries that mode onto the target, so - a config file that used to be 0644 becomes owner-only. On Windows - where a - real capture session can only run - the mode is not what decides access at all, - and everywhere else these files sit in the user's own data directory, so the - change is a tightening in a private place. Copying the old file's mode across + a config file that used to be 0644 becomes owner-only. On Windows the mode is not + what decides access at all, and everywhere else these files sit in the user's + own data directory - under sudo on Linux too, handed back to that user (below) - + so the change is a tightening in a private place. Copying the old file's mode across would buy nothing and add a failure path to a function that must not have one. Under sudo the temp file is handed back to whoever ran it (``hand_back``) before diff --git a/beantester/portmap.py b/beantester/portmap.py index 2a2fd1ea..e7921433 100644 --- a/beantester/portmap.py +++ b/beantester/portmap.py @@ -430,9 +430,15 @@ def _psutil_row(proto, conn): state = _PSUTIL_STATES.get(conn.status, conn.status) if proto == "TCP" else "" blank = state == "LISTEN" or not raddr local_ip = str(laddr[0]) + pid = conn.pid + # Linux keeps no file for a socket in TIME_WAIT, so psutil names no process for + # it, even to root (measured 2026-10-28, WSL, as a user and as root). No process + # owns it any more, which the native path says with PID 0 - and so does the help. + if pid is None and state == "TIME_WAIT": + pid = 0 return SocketRow(proto, 6 if ":" in local_ip else 4, local_ip, int(laddr[1]), "" if blank else str(raddr[0]), None if blank else int(raddr[1]), - state, None if conn.pid is None else int(conn.pid)) + state, None if pid is None else int(pid)) def _psutil_socket_rows(): @@ -440,7 +446,9 @@ def _psutil_socket_rows(): Raises instead of answering empty: see :func:`socket_rows`. Another account's socket may carry no PID - psutil leaves ``pid`` as None when the OS will not - say, which on Linux is any socket that is not ours unless we run as root. + say, which on Linux is any socket that is not ours unless we run as root, and one + that no process holds any more, as a closing connection (``_psutil_row`` makes + TIME_WAIT's PID 0). """ try: import psutil diff --git a/beantester/repro.py b/beantester/repro.py index 849c70fa..0f604cb7 100644 --- a/beantester/repro.py +++ b/beantester/repro.py @@ -3,7 +3,9 @@ import math import os import re +import shlex import string +import sys import time from .appinfo import TOOL_ID, command_name @@ -151,11 +153,18 @@ def _quote(arg): def settings_to_cli_string(settings, seed=None, simulate=False, scenario=None, loop=False): - # Copy-paste ready: every argument a shell would read its own way is quoted - # (see _quote). The program name follows the build: a frozen user has no - # "python bean_network_tester.py" to paste (appinfo.command_name). + # Copy-paste ready: every argument a shell would read its own way is quoted, for + # the shell of this system - cmd.exe and PowerShell on Windows (_quote), a POSIX + # shell anywhere else (shlex.quote). Double quotes are not enough there: bash + # still expands $( ), $NAME and a backtick inside them, and at a prompt the `!` of + # `chrome,!chromedriver` (history expansion) refused the line or put an earlier + # command in its place (measured 2026-10-28). Single quotes expand nothing. + # The program name follows the build: a frozen user has no + # "python bean_network_tester.py" to paste, and Linux runs it under sudo from the + # virtual environment unless it simulates (appinfo.command_name). args = settings_to_cli(settings, seed, simulate, scenario, loop) - return f"{command_name()} " + " ".join(_quote(a) for a in args) + quote = _quote if sys.platform == "win32" else shlex.quote + return f"{command_name(simulate)} " + " ".join(quote(a) for a in args) def session_command(engine, settings): diff --git a/beantester/tcpestats.py b/beantester/tcpestats.py index 19b26487..ee8d5d4b 100644 --- a/beantester/tcpestats.py +++ b/beantester/tcpestats.py @@ -230,6 +230,16 @@ def supported(): return sys.platform.startswith("linux") and _Linux().can_list() +NOT_HERE_LINUX = "tools.tcphealth.not_here_linux" + + +def why_not(): + """What a system without TCP health says instead (``capabilities.why_not``): on + Linux, that its kernel lists no TCP sockets - the shared words name Linux as a + system the tool works on. Elsewhere nothing of its own.""" + return NOT_HERE_LINUX if sys.platform.startswith("linux") else None + + def needs_rights(): """Whether watching needs administrator rights here: on Windows turning counting on does (above); Linux counts for every socket and lets any user read it.""" diff --git a/lang/en.json b/lang/en.json index 019047eb..5d72eff2 100644 --- a/lang/en.json +++ b/lang/en.json @@ -113,7 +113,7 @@ "dialogs.load_scenario": "Load scenario", "dialogs.loss_burst_help": "Real links rarely lose packets one at a time. A microwave oven, a lift, or a switch between transmitters takes the connection away for a moment, and everything sent in that moment is gone.\n\nThis field says how many packets are lost in a row on average. The \"Loss\" field beside it still decides how much is lost in total, so 5 percent stays 5 percent. It simply arrives in a few clusters instead of being sprinkled evenly.\n\nWhy it matters: 5 percent spread out is something most connections absorb without a visible problem. The same 5 percent in runs of twenty stalls a transfer, breaks a live connection, and sends an application down its reconnect path, which is usually the code you wanted to test.\n\nSet 0 to spread the loss evenly, which is how this tool behaved before this setting existed.\n\nTwo limits worth knowing. A very high loss cannot arrive in very short runs, because runs that short leave too little room between them, so the log tells you what the session will really deliver. And a long run length puts the runs far apart, so a short session may not see one at all. The log says roughly how often to expect them.\n\n\"Link outages (flapping)\" further down is a different thing. It cuts the connection on a fixed cycle, on purpose. This one is random and short, in the middle of otherwise normal traffic.", "dialogs.loss_burst_help_title": "Losses in a row (burst)", - "dialogs.match_help": "An expression is a comma-separated list of terms. Positives are OR-ed, \"!\" subtracts.\n\n value a single value (a bare process name matches as a substring)\n a, b, c a list\n a-b a range, both ends included (80-88 = 80 through 88)\n >N =N <=N comparisons (numbers only)\n * ? wildcards (chrome*, fire?ox)\n re:PATTERN a regular expression (case-insensitive, escape a comma as \\,)\n 10.0.0.0/24 CIDR (IP fields only, IPv4 and IPv6)\n !value exclude\n\nExamples:\n chrome.exe, !chromedriver\n 80, 443, 8000-8100\n 10.0.0.1-10.0.0.50, !10.0.0.7\n re:^fire\n\nAn empty field means \"everything\". A field with only exclusions means \"everything except those\". A term that cannot be evaluated for a packet (a port rule against ICMP, an IPv4 rule against an IPv6 address) simply does not match.", + "dialogs.match_help": "An expression is a comma-separated list of terms. Positives are OR-ed, \"!\" subtracts.\n\n value a single value (a bare process name matches as a substring)\n a, b, c a list\n a-b a range, both ends included (80-88 = 80 through 88)\n >N =N <=N comparisons (numbers only)\n * ? wildcards (chrome*, fire?ox)\n re:PATTERN a regular expression (case-insensitive, escape a comma as \\,)\n 10.0.0.0/24 CIDR (IP fields only, IPv4 and IPv6)\n !value exclude\n\nExamples:\n chrome, !chromedriver\n 80, 443, 8000-8100\n 10.0.0.1-10.0.0.50, !10.0.0.7\n re:^fire\n\nAn empty field means \"everything\". A field with only exclusions means \"everything except those\". A term that cannot be evaluated for a packet (a port rule against ICMP, an IPv4 rule against an IPv6 address) simply does not match.", "dialogs.match_help_title": "Filter expression syntax", "dialogs.missing_library": "Missing library", "dialogs.not_loaded": "Not loaded", @@ -253,7 +253,7 @@ "fields.syn_drop": "Dropped TCP SYN:", "fields.target_all_traffic": "Process targeting is not active - every connection in the traffic filter is being impaired.", "fields.target_dest": "Target dest", - "fields.target_example": "e.g. chrome.exe, 12345, re:^fire", + "fields.target_example": "e.g. chrome, 12345, re:^fire", "fields.target_no_match": "No process matching this target has a connection open right now - traffic is NOT being impaired.", "fields.target_process": "Process:", "fields.unit_b_off": "B (0=off)", @@ -658,7 +658,7 @@ "tips.status": "Whether traffic modification is running. Green = running, red = stopped.", "tips.summary": "What the tool is doing with your traffic right now. 'Preview' = what START will apply. 'Active' = applied now. 'Not applied yet' = click Apply changes.", "tips.syn": "Percent of dropped TCP SYN packets, i.e. those starting a connection. Simulates a connection that won't establish - tests retry logic.", - "tips.target_process": "Limit the effect to chosen apps: process names, PIDs, ranges, wildcards or re: patterns (comma-separated). Prefix ! to exclude, empty = all traffic. See '?' for the full syntax. Requires psutil.", + "tips.target_process": "Limit the effect to chosen apps: process names, PIDs, ranges, wildcards or re: patterns (comma-separated). Prefix ! to exclude, empty = all traffic. See '?' for the full syntax.", "tips.tools_adapters_col_internet": "Traffic to the internet leaves through this adapter, for IPv4, IPv6 or both. Read from the routing table: nothing is sent.", "tips.tools_adapters_col_ipv4": "The adapter's first IPv4 address, with its prefix. The details below list all of them.", "tips.tools_adapters_col_item": "What the row is about.", @@ -916,7 +916,7 @@ "tools.exprtest.use": "Use in the Control field", "tools.exprtest.value": "Test value:", "tools.exprtest.value_process": "Process name:", - "tools.not_here": "This tool is not available on this system yet. For now it works on Windows only.", + "tools.not_here": "This tool is not available on this system yet. For now it works on Windows and Linux.", "tools.ping.address": "Address or name:", "tools.ping.bytes": "B", "tools.ping.chart": "Round trip, last {n} pings", @@ -1034,6 +1034,8 @@ "tools.sockets.help_title": "Sockets", "tools.sockets.note_failed": "Some tables could not be read, so their sockets are missing: {tables}.", "tools.sockets.note_no_pid": "Sockets without a program: {count}. They belong to other accounts, and the system shows their owner only to an administrator.", + "tools.sockets.note_no_pid_linux": "Sockets without a program: {count}. Linux names the programs of other accounts only to root: start the program with sudo to see them. A connection that is closing may have no program any more.", + "tools.sockets.note_no_pid_root": "Sockets without a program: {count}. Linux names no program for them, not even to root. A connection that is closing may have none any more, and a socket can be held from outside this system, for example by another WSL distribution or a container.", "tools.sockets.note_stale": "The rows below were read at {time}.", "tools.sockets.refresh": "Refresh", "tools.sockets.tab": "Sockets", @@ -1081,6 +1083,7 @@ "tools.tcphealth.log_limited": "Destination IP set from the TCP health tool", "tools.tcphealth.log_unreadable": "TCP health: the socket table could not be read, {address} was not added", "tools.tcphealth.needs_admin": "Watching a connection needs administrator rights. Start BeanNetworkTester as administrator.", + "tools.tcphealth.not_here_linux": "TCP health cannot read connections here: this Linux kernel does not list its TCP sockets (sock_diag).", "tools.tcphealth.note.not_measured": "not measured for {code} s", "tools.tcphealth.report_logged": "TCP health report", "tools.tcphealth.state.ended": "Ended", diff --git a/lang/pl.json b/lang/pl.json index 302ed120..d39f446a 100644 --- a/lang/pl.json +++ b/lang/pl.json @@ -113,7 +113,7 @@ "dialogs.load_scenario": "Wczytaj scenariusz", "dialogs.loss_burst_help": "Prawdziwe łącza rzadko gubią pakiety po jednym. Mikrofalówka, winda albo przełączenie się między nadajnikami zabiera połączenie na moment, a wszystko wysłane w tym momencie przepada.\n\nTo pole mówi, ile pakietów ginie pod rząd średnio. Pole \"Utrata\" obok nadal decyduje, ile ginie w sumie, więc 5 procent zostaje 5 procentami. Po prostu przychodzi w kilku skupiskach zamiast równomiernie.\n\nDlaczego to ważne: 5 procent rozłożonych równo większość połączeń wchłania bez widocznego problemu. Te same 5 procent w seriach po dwadzieścia zatrzymuje transfer, zrywa połączenie na żywo i wysyła aplikację w ścieżkę ponownego łączenia, czyli zwykle w ten kod, który chciałeś przetestować.\n\nUstaw 0, żeby rozłożyć stratę równomiernie, czyli tak, jak to narzędzie działało, zanim to ustawienie powstało.\n\nDwa ograniczenia, o których warto wiedzieć. Bardzo wysoka strata nie zmieści się w bardzo krótkich seriach, bo tak krótkie serie zostawiają za mało miejsca między sobą, więc log mówi, ile sesja naprawdę zgubi. A długa seria stawia serie daleko od siebie, więc krótka sesja może nie zobaczyć żadnej. Log mówi, jak często można się ich spodziewać.\n\n\"Przerwy w łączu (flapping)\" niżej to co innego. Tamto zrywa połączenie w stałym cyklu i tak ma być. To jest losowe i krótkie, w środku normalnego ruchu.", "dialogs.loss_burst_help_title": "Straty pod rząd (burst)", - "dialogs.match_help": "Wyrażenie to lista członów po przecinku. Pozytywy łączy OR, \"!\" odejmuje.\n\n wartość pojedyncza wartość (goła nazwa procesu działa jak podciąg)\n a, b, c lista\n a-b zakres, obustronnie domknięty (80-88 = od 80 do 88)\n >N =N <=N porównania (tylko liczby)\n * ? wildcardy (chrome*, fire?ox)\n re:WZORZEC wyrażenie regularne (bez wielkości liter, przecinek escapuj \\,)\n 10.0.0.0/24 CIDR (tylko pola IP, IPv4 i IPv6)\n !wartość wykluczenie\n\nPrzykłady:\n chrome.exe, !chromedriver\n 80, 443, 8000-8100\n 10.0.0.1-10.0.0.50, !10.0.0.7\n re:^fire\n\nPuste pole = wszystko. Pole z samymi wykluczeniami = wszystko oprócz nich. Człon, którego nie da się ocenić dla danego pakietu (reguła portu wobec ICMP, reguła IPv4 wobec adresu IPv6) po prostu nie pasuje.", + "dialogs.match_help": "Wyrażenie to lista członów po przecinku. Pozytywy łączy OR, \"!\" odejmuje.\n\n wartość pojedyncza wartość (goła nazwa procesu działa jak podciąg)\n a, b, c lista\n a-b zakres, obustronnie domknięty (80-88 = od 80 do 88)\n >N =N <=N porównania (tylko liczby)\n * ? wildcardy (chrome*, fire?ox)\n re:WZORZEC wyrażenie regularne (bez wielkości liter, przecinek escapuj \\,)\n 10.0.0.0/24 CIDR (tylko pola IP, IPv4 i IPv6)\n !wartość wykluczenie\n\nPrzykłady:\n chrome, !chromedriver\n 80, 443, 8000-8100\n 10.0.0.1-10.0.0.50, !10.0.0.7\n re:^fire\n\nPuste pole = wszystko. Pole z samymi wykluczeniami = wszystko oprócz nich. Człon, którego nie da się ocenić dla danego pakietu (reguła portu wobec ICMP, reguła IPv4 wobec adresu IPv6) po prostu nie pasuje.", "dialogs.match_help_title": "Składnia wyrażeń filtrujących", "dialogs.missing_library": "Brak biblioteki", "dialogs.not_loaded": "Nie wczytano", @@ -253,7 +253,7 @@ "fields.syn_drop": "Gubione TCP SYN:", "fields.target_all_traffic": "Celowanie w proces nie działa - modyfikowane jest każde połączenie objęte filtrem ruchu.", "fields.target_dest": "Celuj w cel", - "fields.target_example": "np. chrome.exe, 12345, re:^fire", + "fields.target_example": "np. chrome, 12345, re:^fire", "fields.target_no_match": "Żaden proces pasujący do tego celu nie ma teraz otwartego połączenia - ruch NIE jest modyfikowany.", "fields.target_process": "Proces:", "fields.unit_b_off": "B (0=wył.)", @@ -658,7 +658,7 @@ "tips.status": "Czy modyfikacja ruchu działa. Zielony = działa, czerwony = zatrzymany.", "tips.summary": "Co narzędzie właśnie robi z ruchem. „Podgląd” = co zastosuje START. „Aktywne” = zastosowane teraz. „Niezastosowane zmiany” = kliknij Zastosuj zmiany.", "tips.syn": "Procent gubionych pakietów TCP SYN, czyli tych rozpoczynających połączenie. Symuluje sytuację, gdy połączenie nie chce się nawiązać - test ponawiania prób.", - "tips.target_process": "Ogranicz działanie do wybranych aplikacji: nazwy procesów, PID-y, zakresy, wildcardy lub wzorce re: (po przecinku). Poprzedź !, aby wykluczyć, puste = cały ruch. Pełna składnia pod „?”. Wymaga psutil.", + "tips.target_process": "Ogranicz działanie do wybranych aplikacji: nazwy procesów, PID-y, zakresy, wildcardy lub wzorce re: (po przecinku). Poprzedź !, aby wykluczyć, puste = cały ruch. Pełna składnia pod „?”.", "tips.tools_adapters_col_internet": "Ruch do internetu wychodzi przez tę kartę, dla IPv4, IPv6 albo obu. Odczytane z tablicy tras: nic nie jest wysyłane.", "tips.tools_adapters_col_ipv4": "Pierwszy adres IPv4 karty, z prefiksem. Szczegóły poniżej wymieniają wszystkie.", "tips.tools_adapters_col_item": "Czego dotyczy wiersz.", @@ -916,7 +916,7 @@ "tools.exprtest.use": "Użyj w polu na Sterowaniu", "tools.exprtest.value": "Wartość do sprawdzenia:", "tools.exprtest.value_process": "Nazwa procesu:", - "tools.not_here": "To narzędzie nie jest jeszcze dostępne na tym systemie. Na razie działa tylko w Windows.", + "tools.not_here": "To narzędzie nie jest jeszcze dostępne na tym systemie. Na razie działa w Windows i w Linuksie.", "tools.ping.address": "Adres lub nazwa:", "tools.ping.bytes": "B", "tools.ping.chart": "W obie strony, ostatnie {n} pingów", @@ -1034,6 +1034,8 @@ "tools.sockets.help_title": "Gniazda", "tools.sockets.note_failed": "Części tabel nie udało się odczytać, więc brakuje ich gniazd: {tables}.", "tools.sockets.note_no_pid": "Gniazda bez programu: {count}. Należą do innych kont, a system pokazuje ich właściciela tylko administratorowi.", + "tools.sockets.note_no_pid_linux": "Gniazda bez programu: {count}. Linux podaje programy innych kont tylko rootowi: uruchom program przez sudo, żeby je zobaczyć. Połączenie, które się zamyka, może już nie mieć programu.", + "tools.sockets.note_no_pid_root": "Gniazda bez programu: {count}. Linux nie podaje dla nich programu nawet rootowi. Połączenie, które się zamyka, może już go nie mieć, a gniazdo może trzymać coś spoza tego systemu, na przykład inna dystrybucja WSL albo kontener.", "tools.sockets.note_stale": "Wiersze poniżej odczytano o {time}.", "tools.sockets.refresh": "Odśwież", "tools.sockets.tab": "Gniazda", @@ -1081,6 +1083,7 @@ "tools.tcphealth.log_limited": "Adres IP celu ustawiony z narzędzia Zdrowie TCP", "tools.tcphealth.log_unreadable": "Zdrowie TCP: nie udało się odczytać tablicy gniazd, {address} nie dodano", "tools.tcphealth.needs_admin": "Obserwowanie połączenia wymaga uprawnień administratora. Uruchom BeanNetworkTester jako administrator.", + "tools.tcphealth.not_here_linux": "Zdrowie TCP nie odczyta tu połączeń: to jądro Linuksa nie podaje listy swoich gniazd TCP (sock_diag).", "tools.tcphealth.note.not_measured": "bez pomiaru od {code} s", "tools.tcphealth.report_logged": "raport zdrowia TCP", "tools.tcphealth.state.ended": "Zakończone", diff --git a/lang/zh.json b/lang/zh.json index 34ecead1..4a91c6e7 100644 --- a/lang/zh.json +++ b/lang/zh.json @@ -113,7 +113,7 @@ "dialogs.load_scenario": "加载场景", "dialogs.loss_burst_help": "真实链路很少一个一个地丢包。微波炉、电梯,或者在不同基站之间切换,都会让连接中断片刻,而这段时间里发出的数据全部消失。\n\n这个设置表示平均连续丢失多少个数据包。旁边的\"丢包\"仍然决定总共丢多少,所以 5% 依然是 5%,只是集中成几串出现,而不是均匀分散。\n\n为什么重要:均匀分散的 5% 丢包,大多数连接都能吸收,看不出明显问题。同样的 5% 如果以每串二十个的方式出现,就会让传输停滞、让实时连接断开,并促使应用走重连流程,而那通常正是你想测试的代码。\n\n设为 0 表示均匀分散丢包,也就是本工具在这个设置出现之前的行为。\n\n有两点限制值得了解。很高的丢包率无法以很短的串出现,因为那么短的串之间留不下足够的间隔,所以日志会告诉你本次会话实际会丢多少。另外,串越长,两串之间的间隔就越远,短时间的会话可能一串都遇不到。日志会说明大致的出现频率。\n\n下方的\"链路中断(周期断线)\"是另一回事,它按固定周期切断连接,这是刻意设计的。而这里是随机且短暂的,发生在原本正常的流量之中。", "dialogs.loss_burst_help_title": "连续丢包(burst)", - "dialogs.match_help": "表达式由逗号分隔的多个项目组成。正向项目按“或”匹配,“!”用于排除。\n\n value 单个值(直接填写进程名时按子字符串匹配)\n a, b, c 列表\n a-b 范围,包含两端(80-88 表示 80 到 88)\n >N =N <=N 数值比较(仅限数字)\n * ? 通配符(chrome*、fire?ox)\n re:PATTERN 正则表达式(不区分大小写,模式内的逗号要写成 \\,)\n 10.0.0.0/24 CIDR(仅适用于 IP 字段,支持 IPv4 和 IPv6)\n !value 排除\n\n示例:\n chrome.exe, !chromedriver\n 80, 443, 8000-8100\n 10.0.0.1-10.0.0.50, !10.0.0.7\n re:^fire\n\n空字段表示“全部”。只有排除项的字段表示“除这些之外的全部”。若某个条件无法用于某个数据包(例如用端口规则匹配 ICMP,或用 IPv4 规则匹配 IPv6 地址),该条件只会视为不匹配。", + "dialogs.match_help": "表达式由逗号分隔的多个项目组成。正向项目按“或”匹配,“!”用于排除。\n\n value 单个值(直接填写进程名时按子字符串匹配)\n a, b, c 列表\n a-b 范围,包含两端(80-88 表示 80 到 88)\n >N =N <=N 数值比较(仅限数字)\n * ? 通配符(chrome*、fire?ox)\n re:PATTERN 正则表达式(不区分大小写,模式内的逗号要写成 \\,)\n 10.0.0.0/24 CIDR(仅适用于 IP 字段,支持 IPv4 和 IPv6)\n !value 排除\n\n示例:\n chrome, !chromedriver\n 80, 443, 8000-8100\n 10.0.0.1-10.0.0.50, !10.0.0.7\n re:^fire\n\n空字段表示“全部”。只有排除项的字段表示“除这些之外的全部”。若某个条件无法用于某个数据包(例如用端口规则匹配 ICMP,或用 IPv4 规则匹配 IPv6 地址),该条件只会视为不匹配。", "dialogs.match_help_title": "过滤表达式语法", "dialogs.missing_library": "缺少库", "dialogs.not_loaded": "未加载", @@ -253,7 +253,7 @@ "fields.syn_drop": "丢弃 TCP SYN:", "fields.target_all_traffic": "进程定位未生效。流量过滤器中的所有连接都在受到弱网影响。", "fields.target_dest": "目标地址", - "fields.target_example": "例如 chrome.exe、12345、re:^fire", + "fields.target_example": "例如 chrome、12345、re:^fire", "fields.target_no_match": "目前没有符合此目标的进程打开网络连接。流量当前不会受到弱网影响。", "fields.target_process": "进程:", "fields.unit_b_off": "B(0=关闭)", @@ -658,7 +658,7 @@ "tips.status": "流量修改当前是否正在运行。绿色 = 运行中,红色 = 已停止。", "tips.summary": "显示本工具此刻正在如何处理流量。“预览”表示点击“开始”后将应用的效果。“已启用”表示当前已经应用。“尚未应用”表示需要点击“应用更改”。", "tips.syn": "丢弃指定百分比的 TCP SYN 数据包,即用于建立连接的数据包。可模拟连接无法建立并测试重试逻辑。", - "tips.target_process": "把弱网效果限制到指定应用:支持进程名、PID、范围、通配符或 re: 正则表达式,多个项目用逗号分隔。前加“!”可排除,留空 = 全部流量。点击“?”查看完整语法。需要 psutil。", + "tips.target_process": "把弱网效果限制到指定应用:支持进程名、PID、范围、通配符或 re: 正则表达式,多个项目用逗号分隔。前加“!”可排除,留空 = 全部流量。点击“?”查看完整语法。", "tips.tools_adapters_col_internet": "发往互联网的流量经由此网卡发出(IPv4、IPv6 或两者)。读取自路由表:不发送任何数据。", "tips.tools_adapters_col_ipv4": "网卡的第一个 IPv4 地址及其前缀。下方详细信息列出全部地址。", "tips.tools_adapters_col_item": "此行的内容。", @@ -916,7 +916,7 @@ "tools.exprtest.use": "在“控制”页的字段中使用", "tools.exprtest.value": "测试值:", "tools.exprtest.value_process": "进程名称:", - "tools.not_here": "此工具在此系统上暂不可用。目前仅适用于 Windows。", + "tools.not_here": "此工具在此系统上暂不可用。目前仅适用于 Windows 和 Linux。", "tools.ping.address": "地址或名称:", "tools.ping.bytes": "字节", "tools.ping.chart": "往返时间,最近 {n} 次 ping", @@ -1034,6 +1034,8 @@ "tools.sockets.help_title": "套接字", "tools.sockets.note_failed": "部分表无法读取,因此缺少其中的套接字:{tables}。", "tools.sockets.note_no_pid": "没有程序的套接字:{count}。它们属于其他账户,系统只向管理员显示其所有者。", + "tools.sockets.note_no_pid_linux": "没有程序的套接字:{count}。Linux 只向 root 显示其他账户的程序:请用 sudo 启动程序以查看。正在关闭的连接可能已不再属于任何程序。", + "tools.sockets.note_no_pid_root": "没有程序的套接字:{count}。即使对 root,Linux 也不显示其所属程序。正在关闭的连接可能已不再属于任何程序,套接字也可能由此系统之外的程序持有,例如另一个 WSL 发行版或容器。", "tools.sockets.note_stale": "下面各行读取于 {time}。", "tools.sockets.refresh": "刷新", "tools.sockets.tab": "套接字", @@ -1081,6 +1083,7 @@ "tools.tcphealth.log_limited": "已通过 TCP 健康工具设置目标 IP", "tools.tcphealth.log_unreadable": "TCP 健康:无法读取套接字表,未添加 {address}", "tools.tcphealth.needs_admin": "观察连接需要管理员权限。请以管理员身份运行 BeanNetworkTester。", + "tools.tcphealth.not_here_linux": "TCP 健康无法在此读取连接:此 Linux 内核不提供其 TCP 套接字列表(sock_diag)。", "tools.tcphealth.note.not_measured": "已 {code} 秒未测量", "tools.tcphealth.report_logged": "TCP 健康报告", "tools.tcphealth.state.ended": "已结束", diff --git a/tests/test_capabilities.py b/tests/test_capabilities.py index 68040aea..087fc561 100644 --- a/tests/test_capabilities.py +++ b/tests/test_capabilities.py @@ -145,11 +145,11 @@ def test_a_linux_without_the_right_to_ping_says_how_to_get_it(monkeypatch): monkeypatch.setattr(icmpsock, "kind", lambda: None) monkeypatch.setattr(sys, "platform", "linux") monkeypatch.setattr(paths, "running_as_root", lambda: False) - monkeypatch.setattr(tcpestats._Linux, "can_list", lambda _self: False) + monkeypatch.setattr(tcpreset._Linux, "can_destroy", lambda _self: False) check("Linux: Ping's own words", capabilities.why_not("ping") == icmp.NOT_HERE_LINUX, f"({capabilities.why_not('ping')})") - check("the others keep the shared ones", - capabilities.why_not("tcp_health") == capabilities.NOT_HERE) + check("a tool without words of its own keeps the shared ones", + capabilities.why_not("tcp_reset") == capabilities.NOT_HERE) monkeypatch.setattr(paths, "running_as_root", lambda: True) check("as root: the words for root", capabilities.why_not("ping") == icmp.NOT_HERE_ROOT, f"({capabilities.why_not('ping')})") @@ -163,6 +163,29 @@ def test_a_linux_without_the_right_to_ping_says_how_to_get_it(monkeypatch): "sudo" not in root and "CAP_NET_RAW" in root and "ping_group_range" in root, f"({root})") +def test_a_linux_kernel_without_the_socket_list_says_so_for_tcp_health(monkeypatch): + """TCP health works on Linux, so on a Linux whose kernel lists no TCP sockets the + shared words - which name Linux as a system the tools work on - would read as a + contradiction. The module says what is missing instead, and the shared words + name both systems for the systems that have neither.""" + monkeypatch.setattr(sys, "platform", "linux") + monkeypatch.setattr(tcpestats._Linux, "can_list", lambda _self: False) + check("Linux: TCP health's own words", + capabilities.why_not("tcp_health") == tcpestats.NOT_HERE_LINUX, + f"({capabilities.why_not('tcp_health')})") + monkeypatch.setattr(sys, "platform", "darwin") + check("another system: the shared words", + capabilities.why_not("tcp_health") == capabilities.NOT_HERE) + for name in ("en.json", "pl.json", "zh.json"): + with open(os.path.join(ROOT, "lang", name), encoding="utf-8") as f: + texts = json.load(f) + shared = texts[capabilities.NOT_HERE] + check(f"{name}: the shared words name Linux too", # Polish declines it + "Linux" in shared or "Linuks" in shared, f"({shared})") + check(f"{name}: and TCP health's name the kernel's list", + "sock_diag" in texts[tcpestats.NOT_HERE_LINUX]) + + def test_every_reason_is_said_in_every_language(): languages = [n for n in os.listdir(os.path.join(ROOT, "lang")) if n.endswith(".json")] check("the language files were found", len(languages) >= 3, f"({languages})") diff --git a/tests/test_capture_backend.py b/tests/test_capture_backend.py index 44c4153f..e6cefc5d 100644 --- a/tests/test_capture_backend.py +++ b/tests/test_capture_backend.py @@ -541,6 +541,17 @@ def test_the_linux_doctor_rows_say_what_is_missing_and_fail_what_stops_a_capture all(row[1] == "ok" for rows in (ready, by_caps) for row in rows.values())) +def test_doctor_on_a_system_without_a_capture_driver_names_both_that_have_one(monkeypatch): + """Off Windows and Linux (``select`` gives the WinDivert path, which has no words + for this row) the platform row said capture needs Windows - Linux captures too.""" + monkeypatch.setattr(capture_backend, "current", lambda: capture_backend.WinDivertBackend()) + monkeypatch.setattr(driver, "is_windows", lambda: False) + _ok, checks = driver.doctor() + row = next(c for c in checks if c[0] == "platform") + check("the platform row names Windows and Linux", "Windows or Linux" in row[2] and + "--simulate" in row[2] and row[1] == "warn", f"({row})") + + def test_doctor_off_windows_takes_the_linux_backends_rows(monkeypatch, tmp_path): monkeypatch.setitem(sys.modules, "pydivert", _ebpf_pydivert()[0]) # A ready machine has BTF. Without the file this test's "healthy" verdict rested diff --git a/tests/test_cli.py b/tests/test_cli.py index a94a6959..f1ad5cca 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -217,8 +217,12 @@ def test_cli_rejects_bad_expressions(): check(f"CLI rejects {argv}", raised) -def test_cli_expression_survives_the_repro_command(): - from beantester import (build_arg_parser, config_from_args, settings_to_cli, +def test_cli_expression_survives_the_repro_command(monkeypatch): + """Quoted for the shell of the system: double quotes for cmd.exe and PowerShell, + single quotes for bash, which still reads the `!` and `$` inside double ones.""" + import sys + + from beantester import (build_arg_parser, config_from_args, paths, settings_to_cli, settings_to_cli_string, DEFAULT_SETTINGS) s = dict(DEFAULT_SETTINGS) s.update(dst_ip="10.0.0.1-10.0.0.50,!10.0.0.7", dst_port="80,443,!8080", @@ -228,7 +232,14 @@ def test_cli_expression_survives_the_repro_command(): ok = (parsed["dst_ip"] == s["dst_ip"] and parsed["dst_port"] == s["dst_port"] and parsed["target"] == s["target"]) check("CLI command reproduces the expressions verbatim", ok, f"({parsed})") + monkeypatch.setattr(sys, "platform", "win32") cmd = settings_to_cli_string(s, seed=7) - check("expressions are quoted in the copy-paste command", + check("Windows: expressions are double-quoted in the copy-paste command", '"10.0.0.1-10.0.0.50,!10.0.0.7"' in cmd and '"80,443,!8080"' in cmd, f"({cmd})") + monkeypatch.setattr(sys, "platform", "linux") + monkeypatch.setattr(paths, "running_as_root", lambda: False) # asks geteuid, not on Windows + cmd = settings_to_cli_string(s, seed=7) + check("Linux: single-quoted, so bash reads no `!` in them", + "'10.0.0.1-10.0.0.50,!10.0.0.7'" in cmd and "'chrome,!chromedriver'" in cmd + and '"' not in cmd, f"({cmd})") diff --git a/tests/test_cli_runtime.py b/tests/test_cli_runtime.py index 7a981a5d..e6ed75b4 100644 --- a/tests/test_cli_runtime.py +++ b/tests/test_cli_runtime.py @@ -697,7 +697,10 @@ def test_the_repro_command_repeats_the_scenario_and_the_simulation(tmp_path): check("repro: the report's command is the summary's", written == command, f"({written!r} vs {command!r})") parts = [p.strip('"') for p in shlex.split(command, posix=False)] - argv = parts[2:] if parts[0] == "python" else parts[1:] + # Everything up to the script is how this system runs it (appinfo.command_name): + # "python" on Windows, sudo and the virtual environment on Linux, or the exe. + argv = parts[parts.index("bean_network_tester.py") + 1:] \ + if "bean_network_tester.py" in parts else parts[1:] args = build_arg_parser().parse_args(argv) check("repro: the command names the scenario as it was typed, and loops it", args.scenario == scen and args.loop, f"({command})") @@ -1722,9 +1725,10 @@ def test_duration_survives_the_repro_command(): check("repro: --duration is reproduced", "--duration" in argv, f"({argv})") parsed = config_from_args(build_arg_parser().parse_args(argv))["settings"] check("repro: the duration round-trips", parsed["duration"] == 25) + from beantester import appinfo check("repro: the command names this build", - settings_to_cli_string(s).startswith("python bean_network_tester.py") - or settings_to_cli_string(s).startswith("BeanNetworkTester.exe")) + settings_to_cli_string(s).startswith(appinfo.command_name() + " "), + f"({settings_to_cli_string(s)})") def test_repro_command_follows_a_frozen_build(monkeypatch): diff --git a/tests/test_code_shape.py b/tests/test_code_shape.py index 604b5c4d..60b4226a 100644 --- a/tests/test_code_shape.py +++ b/tests/test_code_shape.py @@ -142,8 +142,9 @@ # `engine.py` (766), which joins the crowd. FILES_NEAR_CEILING went from 1 to 2 for # that, by the OWNER's decision of the same day, over the alternative of keeping # three lines in `app.py` written longer than they need to be to hold the count. -# `cli.py` is 757, seven lines under the band. -FILE_CEILING = 1086 # beantester/gui/app.py +# `cli.py` is 757, seven lines under the band. 1086 -> 1083 on 2026-10-27: the choice +# of the geometry the window opens with moved into `scaling.restored_geometry`. +FILE_CEILING = 1083 # beantester/gui/app.py # 🔴 THE SECOND KNOB. A ceiling on the worst single item sees one thing growing # to a record and is blind to everything creeping upward together: five files at diff --git a/tests/test_gui_helpers.py b/tests/test_gui_helpers.py index 7bef3b97..71008d6d 100644 --- a/tests/test_gui_helpers.py +++ b/tests/test_gui_helpers.py @@ -74,6 +74,48 @@ def test_geometry_fits_rejects_stale_saved_geometry(): check("geometry: garbage is rejected", not scaling.geometry_fits("nonsense", 1366, 768)) +def test_a_saved_window_below_the_minimum_grows_on_its_own_monitor(): + """A window an older, narrower version saved: grown to the minimum, and moved + left only as far as the growth would push it past its monitor's edge.""" + grow = scaling.grown_to + check("grown in place where there is room", + grow("760x920+100+50", 960, 560, 1920, 1080) == "960x920+100+50") + check("moved left at the right edge, not 200 px past it", + grow("760x920+1150+50", 960, 560, 1920, 1080) == "960x920+960+50", + f"({grow('760x920+1150+50', 960, 560, 1920, 1080)})") + left_of_primary = lambda x, y: (-1920, 0, 0, 1040) # noqa: E731 + on_left = grow("760x900+-770+40", 960, 560, 1920, 1080, left_of_primary) + check("a monitor left of the primary one: kept on it", on_left == "960x900+-960+40", + f"({on_left})") + check("...and the result is still a geometry that is kept", + scaling.geometry_fits(on_left, 1920, 1080, left_of_primary)) + check("a height below the minimum grows too, and up from the bottom", + grow("960x400+0+700", 960, 560, 1920, 1080) == "960x560+0+520") + check("never past the monitor's left edge", + grow("760x600+50+40", 960, 560, 900, 1080) == "960x600+0+40") + check("a window with the minimum is left exactly as saved", + grow("1200x900+700+30", 960, 560, 1920, 1080) == "1200x900+700+30") + check("garbage is handed on for geometry_fits to refuse", + grow("nonsense", 960, 560, 1920, 1080) == "nonsense") + + +def test_a_narrow_window_saved_on_an_unplugged_monitor_opens_in_the_centre(): + """Growing moves a window's corner onto the monitor nearest to it, so a narrow + window saved on a monitor since unplugged was grown onto the edge of the one left + and kept there. It is judged as saved first, and goes to the centre (README).""" + saved, minimum = "760x920+3000+50", (960, 560) + w, h, x, y = scaling.initial_geometry(1920, 1080) + centre = f"{w}x{h}+{x}+{y}" + gone = lambda x, y: (0, 0, 1920, 1040) # noqa: E731 + opened = scaling.restored_geometry(saved, minimum, 1920, 1080, gone) + check("the monitor is gone: the centre of the screen", opened == centre, f"({opened})") + attached = lambda x, y: (1920, 0, 3840, 1040) if x >= 1900 else (0, 0, 1920, 1040) # noqa: E731 + kept = scaling.restored_geometry(saved, minimum, 1920, 1080, attached) + check("the monitor is there: grown and kept on it", kept == "960x920+2880+50", f"({kept})") + check("garbage: the centre", scaling.restored_geometry("nonsense", minimum, 1920, 1080, + gone) == centre) + + def _primary_only(x, y): """One monitor, 3840x2088 of work area. The nearest one is always this one.""" return (0, 0, 3840, 2088) diff --git a/tests/test_gui_layout.py b/tests/test_gui_layout.py index 3fab6e33..91bbe5ac 100644 --- a/tests/test_gui_layout.py +++ b/tests/test_gui_layout.py @@ -37,19 +37,33 @@ def test_the_main_window_goes_back_to_the_second_monitor_it_was_left_on(): from beantester import winenv winenv.monitor_work_area = lambda x, y: (1920, 0, 3000, 1920) - app.ui.set("geometry", "800x600+2400+100") + app.ui.set("geometry", "1000x600+2400+100") app._restore_geometry() - assert root.kw["geometry"] == "800x600+2400+100", root.kw["geometry"] + assert root.kw["geometry"] == "1000x600+2400+100", root.kw["geometry"] # ...and with that monitor unplugged the window must NOT be put back there. winenv.monitor_work_area = lambda x, y: (0, 0, 1920, 1080) app._restore_geometry() spec = root.kw["geometry"] - assert spec != "800x600+2400+100", spec + assert spec != "1000x600+2400+100", spec assert int(spec.split("+")[1]) < 1920, spec """) +def test_a_window_saved_narrower_than_today_grows_and_stays_on_its_monitor(): + """Wiring, not arithmetic (``scaling.grown_to``): a size an older version saved, + when its window was narrower, is grown here - where the corner can move - not by + Tk, which grows a window to the right and put this one 200 px past the edge.""" + run_gui(""" + from beantester.gui import scaling + min_w, min_h = scaling.min_window_size(screen_w=1920, screen_h=1080) + app.ui.set("geometry", f"{min_w - 200}x900+{1920 - (min_w - 200) - 10}+40") + app._restore_geometry() + spec = root.kw["geometry"] + assert spec == f"{min_w}x900+{1920 - min_w}+40", (spec, min_w) + """) + + def test_the_main_window_tells_its_minimum_which_screen_it_is_on(): """Wiring, not arithmetic: `app._restore_geometry` has to hand the screen over. @@ -487,6 +501,39 @@ def test_the_render_check_measures_every_row_of_tabs(): {"squeezed", "selftest"} <= counted, f"({sorted(counted)})") +def test_the_render_check_measures_every_table_that_cannot_scroll_sideways(): + """A column past the edge of a table without a sideways scrollbar is cut, header + and all: TCP health's list asked 699 to 716 px of 691 at the old 760 px window, + in every language and on both systems (2026-10-27), and the check said OK. It now + measures each such table on every surface, and first proves on tables made to + fail that it can tell one that does not fit from one that does - real Tk only, + so here the wiring is pinned on the source.""" + func = next(n for n in ast.walk(_parsed("tools", "ci_gui_render.py")) + if isinstance(n, ast.FunctionDef) and n.name == "check_language") + scan = next(n for n in ast.walk(func) + if isinstance(n, ast.FunctionDef) and n.name == "scan") + in_scan = {n.func.id for n in ast.walk(scan) + if isinstance(n, ast.Call) and isinstance(n.func, ast.Name)} + check("every surface's scan measures its tables", "_cut_tables" in in_scan, + f"({sorted(in_scan)})") + called = {n.func.id for n in ast.walk(func) + if isinstance(n, ast.Call) and isinstance(n.func, ast.Name)} + check("the check first proves it can fail", "_table_check_can_fail" in called, + f"({sorted(called)})") + verdict = next(n for n in ast.walk(func) + if isinstance(n, ast.Assign) + and any(isinstance(t, ast.Name) and t.id == "ok" for t in n.targets)) + counted = {n.id for n in ast.walk(verdict.value) if isinstance(n, ast.Name)} + check("a cut table fails the run", "tables" in counted, f"({sorted(counted)})") + # ...and a PLACED widget past its parent: the table's empty note was one (Linux, + # Polish, 905 px in 891), cut by the frame while it had all the width it asked for + check("every surface's scan measures what is placed", "_overflowing_placed" in in_scan, + f"({sorted(in_scan)})") + check("that check first proves it can fail", "_placed_check_can_fail" in called, + f"({sorted(called)})") + check("a cut placed widget fails the run", "placed" in counted, f"({sorted(counted)})") + + def test_the_notebooks_inside_a_page_have_the_narrower_tabs(): """The page tabs keep their padding; a notebook inside a page has its own, narrower. diff --git a/tests/test_matchers.py b/tests/test_matchers.py index 97fd3493..a9326ca6 100644 --- a/tests/test_matchers.py +++ b/tests/test_matchers.py @@ -182,6 +182,34 @@ def test_process_name_substring_stays_backward_compatible(): check("bare name rejects others", not m.matches(104, "firefox.exe")) +def test_the_process_example_the_program_shows_finds_it_on_both_systems(): + """The hint under the field, the "?" sheet and ``--help`` show "chrome": a bare + name, which finds Chrome as Windows names it (chrome.exe) and as Linux does + (chrome). The example before, "chrome.exe", found nothing on Linux.""" + import json + import os + + from beantester import cli + from fakes import LANG_DIR, LANGS + m = _proc("chrome, !chromedriver") + check("the example finds Windows' name and Linux's", + m.matches(1, "chrome.exe") and m.matches(2, "chrome")) + check("and leaves out what it says it leaves out", + not m.matches(3, "chromedriver") and not m.matches(4, "chromedriver.exe")) + for code in LANGS: + with open(os.path.join(LANG_DIR, f"{code}.json"), encoding="utf-8") as f: + texts = json.load(f) + for key in ("fields.target_example", "dialogs.match_help"): + check(f"lang/{code}.json: {key} shows a name that finds it on Linux", + "chrome.exe" not in texts[key] and "chrome" in texts[key], f"({texts[key]})") + # The field's tip said "Requires psutil": not on Windows, which targets from its + # own tables, and psutil is installed with the program everywhere. + check(f"lang/{code}.json: the field's tip sends nobody to psutil", + "psutil" not in texts["tips.target_process"]) + text = cli.build_arg_parser().format_help() + check("--help too", "chrome.exe" not in text and "--target chrome " in text) + + def test_process_list_exclusions_and_wildcards(): m = _proc("chrome, !chromedriver") check("name list minus an exclusion", m.matches(101, "chrome.exe")) diff --git a/tests/test_mutate_runner.py b/tests/test_mutate_runner.py index 3aa905e0..82b368dc 100644 --- a/tests/test_mutate_runner.py +++ b/tests/test_mutate_runner.py @@ -54,6 +54,31 @@ def test_more_than_one_filter_or_an_unknown_option_is_refused(capsys, monkeypatc check("nothing ran", not ran, f"({ran})") +def test_an_entry_that_does_not_compile_fails_every_kind_of_run(capsys, monkeypatch): + """A mutation that does not compile reddens every test, so it proves nothing. A + --changed run passed with one (2026-10-27): only the canary's BROKEN was counted.""" + entry = mutate.MUTATIONS[0] + monkeypatch.setattr(mutate, "apply_one", lambda e: ("BROKEN", "does not compile")) + code, out, _err = _main(capsys, entry["label"]) + check("a filtered run fails and names the entry", + code == 1 and "BROKEN ENTRIES" in out and entry["label"] in out, f"({code}, {out})") + monkeypatch.setattr(mutate, "changed_files", lambda ref: {entry["file"]}) + code, out, _err = _main(capsys, "--changed", "origin/master") + check("so does a --changed run", code == 1 and "BROKEN ENTRIES" in out, f"({code})") + # The full run: the canary's BROKEN is the one it must have, and no other. + monkeypatch.setattr(mutate, "apply_one", lambda e: ( + "BROKEN" if e is mutate.CANARY or e is entry else "caught", "")) + code, out, _err = _main(capsys) + check("a full run with one more BROKEN than the canary fails", + code == 1 and "BROKEN ENTRIES" in out and "CANARY DID NOT FIRE" not in out, + f"({code})") + monkeypatch.setattr(mutate, "apply_one", lambda e: ( + "BROKEN" if e is mutate.CANARY else "caught", "")) + code, out, _err = _main(capsys) + check("and only the canary BROKEN passes", code == 0 and "BROKEN ENTRIES" not in out, + f"({code})") + + def test_a_filter_that_matches_still_runs_only_its_entries(capsys, monkeypatch): """The canary for this file: the refusals above must not refuse a good filter.""" ran = [] diff --git a/tests/test_mutation_registry.py b/tests/test_mutation_registry.py index c56763aa..59328710 100644 --- a/tests/test_mutation_registry.py +++ b/tests/test_mutation_registry.py @@ -250,9 +250,8 @@ # old primary-screen answer, so nothing looks broken. "label": "gui: the main window validates its geometry against the primary screen", "file": "beantester/gui/app.py", - "old": " if saved and geometry_fits(saved, screen_w, screen_h," - " winenv.monitor_work_area):", - "new": " if saved and geometry_fits(saved, screen_w, screen_h):", + "old": " screen_w, screen_h, winenv.monitor_work_area)\n", + "new": " screen_w, screen_h)\n", "test": "test_the_main_window_goes_back_to_the_second_monitor_it_was_left_on", }, { @@ -9686,8 +9685,8 @@ { "label": "window: the main window does not say which screen its minimum is for", "file": "beantester/gui/app.py", - "old": " root.minsize(*min_window_size(screen_w=screen_w, screen_h=screen_h))\n", - "new": " root.minsize(*min_window_size())\n", + "old": " minimum = min_window_size(screen_w=screen_w, screen_h=screen_h)\n", + "new": " minimum = min_window_size()\n", "test": "test_the_main_window_tells_its_minimum_which_screen_it_is_on", }, { @@ -9739,6 +9738,197 @@ "new": " selftest = []\n", "test": "test_the_render_check_measures_every_row_of_tabs", }, + { + # 2026-10-27: TCP health's list was cut at 760 px and the check said OK. + "label": "render check: a surface's tables are not measured", + "file": "tools/ci_gui_render.py", + "old": " tables.extend(_cut_tables(root))\n", + "new": " tables.extend([])\n", + "test": "test_the_render_check_measures_every_table_that_cannot_scroll_sideways", + }, + { + "label": "render check: a cut table passes", + "file": "tools/ci_gui_render.py", + "old": " and not tables\n", + "new": " and True\n", + "test": "test_the_render_check_measures_every_table_that_cannot_scroll_sideways", + }, + { + "label": "render check: the table check never proves it can fail", + "file": "tools/ci_gui_render.py", + "old": " selftest += _table_check_can_fail(root)\n", + "new": " selftest += []\n", + "test": "test_the_render_check_measures_every_table_that_cannot_scroll_sideways", + }, + { + "label": "render check: a surface's placed widgets are not measured", + "file": "tools/ci_gui_render.py", + "old": " placed.extend(_overflowing_placed(root))\n", + "new": " placed.extend([])\n", + "test": "test_the_render_check_measures_every_table_that_cannot_scroll_sideways", + }, + { + "label": "render check: a placed widget past its parent passes", + "file": "tools/ci_gui_render.py", + "old": " and not placed\n", + "new": " and True\n", + "test": "test_the_render_check_measures_every_table_that_cannot_scroll_sideways", + }, + { + "label": "render check: the placed-widget check never proves it can fail", + "file": "tools/ci_gui_render.py", + "old": " selftest += _placed_check_can_fail(root)\n", + "new": " selftest += []\n", + "test": "test_the_render_check_measures_every_table_that_cannot_scroll_sideways", + }, + { + "label": "tables: the empty note does not wrap inside its table", + "file": "beantester/gui/widgets/sortable_tree.py", + "old": " self._empty_note.config(wraplength=int(self.tree.winfo_width() * NOTE_SHARE))\n", + "new": " pass\n", + "test": "test_the_empty_note_wraps_inside_its_table", + }, + { + "label": "tables: a column's words do not size it", + "file": "beantester/gui/widgets/sortable_tree.py", + "old": " if need > table._natural.get(col, 0):\n", + "new": " if False:\n", + "test": "test_a_column_is_born_as_wide_as_the_words_it_can_show", + }, + { + "label": "tables: TCP health's list declares no words", + "file": "beantester/gui/toolbox/tcphealth.py", + "old": " make_room(self.table, {column: [say(w) for w in said] for column, said in WORDS.items()})\n", + "new": " make_room(self.table, {})\n", + "test": "test_a_column_is_born_as_wide_as_the_words_it_can_show", + }, + { + "label": "tables: the adapter list declares no words", + "file": "beantester/gui/toolbox/adapters.py", + "old": " make_room(self.table, {column: [say(w) for w in said] for column, said in WORDS.items()})\n", + "new": " make_room(self.table, {})\n", + "test": "test_a_column_is_born_as_wide_as_the_words_it_can_show", + }, + { + "label": "adapters: the column words leave out the proxy row's type", + "file": "beantester/gui/toolbox/adapters.py", + "old": " \"kind\": (*adapters.vocabulary(adapters.KINDS, adapters.KIND_PREFIX), PROXY_KIND)}\n", + "new": " \"kind\": (*adapters.vocabulary(adapters.KINDS, adapters.KIND_PREFIX),)}\n", + "test": "test_a_column_is_born_as_wide_as_the_words_it_can_show", + }, + { + "label": "window: a size saved narrower is not grown here (Tk grows it rightwards)", + "file": "beantester/gui/scaling.py", + "old": " if x + w > right:\n x = max(left, right - w)\n", + "new": "", + "test": "test_a_saved_window_below_the_minimum_grows_on_its_own_monitor", + }, + { + "label": "window: the main window restores a saved size without growing it", + "file": "beantester/gui/scaling.py", + "old": " grown = grown_to(saved, *minimum, screen_w, screen_h, bounds_at)\n", + "new": " grown = str(saved)\n", + "test": "test_a_window_saved_narrower_than_today_grows_and_stays_on_its_monitor", + }, + { + "label": "sockets: Linux says Windows' words on who may see a socket's program", + "file": "beantester/nettools/sockets.py", + "old": " if not (sys.platform if platform is None else platform).startswith(\"linux\"):\n", + "new": " if True:\n", + "test": "test_the_note_on_sockets_without_a_program_says_who_may_see_it_here", + }, + { + "label": "sockets: Linux under sudo is told to use sudo", + "file": "beantester/nettools/sockets.py", + "old": " return NO_OWNER_ROOT if paths.running_as_root() else NO_OWNER_LINUX\n", + "new": " return NO_OWNER_LINUX\n", + "test": "test_the_note_on_sockets_without_a_program_says_who_may_see_it_here", + }, + { + "label": "sockets panel: the note says Windows' words on every system", + "file": "beantester/gui/toolbox/sockets.py", + "old": " lines.append(T(sockets.no_owner_note(), count=unnamed))\n", + "new": " lines.append(T(sockets.NO_OWNER, count=unnamed))\n", + "test": "test_the_table_says_why_it_is_empty_and_what_its_rows_are_missing", + }, + { + "label": "tcpestats: a Linux kernel without the socket list says the shared words", + "file": "beantester/tcpestats.py", + "old": " return NOT_HERE_LINUX if sys.platform.startswith(\"linux\") else None\n", + "new": " return None\n", + "test": "test_a_linux_kernel_without_the_socket_list_says_so_for_tcp_health", + }, + { + # 2026-10-27: Ubuntu 26.04 has no `python`; the copied command was not found. + "label": "repro command: Linux is given Windows' python bean_network_tester.py", + "file": "beantester/appinfo.py", + "old": " return linux_command(simulate)\n", + "new": " return f\"python {LAUNCHER}\"\n", + "test": "test_the_command_follows_the_system_it_runs_on", + }, + { + "label": "repro command: a simulation on Linux is put under sudo", + "file": "beantester/appinfo.py", + "old": " return \" \".join(([] if simulate else [\"sudo\"]) + [interpreter]\n", + "new": " return \" \".join([\"sudo\"] + [interpreter]\n", + "test": "test_the_command_on_linux_is_the_one_its_readme_runs", + }, + { + "label": "repro command: an interpreter outside the checkout is named from it", + "file": "beantester/appinfo.py", + "old": " if place == posixpath.pardir or place.startswith(posixpath.pardir + \"/\"):\n", + "new": " if False:\n", + "test": "test_the_command_on_linux_is_the_one_its_readme_runs", + }, + { + "label": "repro command: the simulate flag does not reach the program name", + "file": "beantester/repro.py", + "old": " return f\"{command_name(simulate)} \" + \" \".join(quote(a) for a in args)\n", + "new": " return f\"{command_name()} \" + \" \".join(quote(a) for a in args)\n", + "test": "test_the_command_follows_the_system_it_runs_on", + }, + { + # 2026-10-28: a narrow window saved on a monitor since unplugged was grown onto + # the edge of the one left and kept there, not centred as the README says. + "label": "window: a saved geometry is grown before it is judged", + "file": "beantester/gui/scaling.py", + "old": " if geometry_fits(saved, screen_w, screen_h, bounds_at):\n grown = ", + "new": " if True:\n grown = ", + "test": "test_a_narrow_window_saved_on_an_unplugged_monitor_opens_in_the_centre", + }, + { + # 2026-10-28: inside double quotes bash expands $( ) and, at a prompt, the `!` + # of `chrome,!chromedriver`. + "label": "repro command: Linux is quoted for cmd.exe", + "file": "beantester/repro.py", + "old": " quote = _quote if sys.platform == \"win32\" else shlex.quote\n", + "new": " quote = _quote\n", + "test": "test_cli_expression_survives_the_repro_command", + }, + { + "label": "repro command: Windows is quoted for bash", + "file": "beantester/repro.py", + "old": " quote = _quote if sys.platform == \"win32\" else shlex.quote\n", + "new": " quote = shlex.quote\n", + "test": "test_cli_expression_survives_the_repro_command", + }, + { + # Linux gives a socket in TIME_WAIT no PID even under sudo, and the Sockets + # note counted it as another account's, or as held from outside the system. + "label": "portmap: a TIME_WAIT socket from psutil keeps an unknown owner", + "file": "beantester/portmap.py", + "old": " if pid is None and state == \"TIME_WAIT\":\n", + "new": " if False:\n", + "test": "test_a_socket_in_time_wait_is_owned_by_no_process_on_the_psutil_path_too", + }, + { + # 2026-10-27: a --changed run passed with "BROKEN: 1 caught: 351". + "label": "mutate: an entry that does not compile passes the run", + "file": "tools/mutate.py", + "old": " return 0 if counts.get(\"SURVIVED\", 0) == 0 and canary_ok and not broken else 1\n", + "new": " return 0 if counts.get(\"SURVIVED\", 0) == 0 and canary_ok else 1\n", + "test": "test_an_entry_that_does_not_compile_fails_every_kind_of_run", + }, ] # The runner's own check: a patch that cannot compile must be reported as BROKEN, not @@ -9799,6 +9989,12 @@ "test_the_depth_metric_still_counts_real_nesting": "2026-08-21, it failed on the first draft of the metric (four nested blocks " "measured three) and passed once the missing level was added", + # Bash on Linux is the shell under test, and the mutation job runs on Windows, + # where the test skips: an entry would come back SURVIVED with nothing behind it. + # The platform choice itself is in MUTATIONS, through a test that runs anywhere. + "test_the_command_survives_bash_with_history_expansion_on": + "2026-10-28, WSL Ubuntu: red on the double-quoted command (`!`, `$( )`, a " + "backtick, `$NAME`, two backslashes), green on the single-quoted one", } # No mutation at all. Naming them is the point: an unproven guard and a guard nobody @@ -9856,6 +10052,41 @@ def test_every_mutation_still_points_at_code_that_exists(): found == 1, f"(found {found} times)") +def _mutated_source_error(entry, texts): + """Why ``entry``'s mutated file would not compile, or "" when it would. Read the + way the runner reads it; a file that is not Python, or a stale pattern (the test + above reports it), is not this check's to judge.""" + path = os.path.join(ROOT, entry["file"]) + if not path.endswith(".py") or not os.path.exists(path): + return "" + if path not in texts: + with open(path, "rb") as handle: + texts[path] = handle.read().decode("utf-8").replace("\r\n", "\n") + if texts[path].count(entry["old"]) != 1: + return "" + try: + compile(texts[path].replace(entry["old"], entry["new"], 1), path, "exec", + dont_inherit=True) + except SyntaxError as exc: + return f"(line {exc.lineno}: {exc.msg})" + return "" + + +def test_every_mutation_still_compiles(): + """A mutation that does not compile proves nothing: every test goes red on it, + whatever it guards, and the runner can only call it BROKEN - on CI, minutes + later. One did (2026-10-27): an `if False:` four spaces deeper than the line it + replaced, which a --changed run then passed as "BROKEN: 1". The suite says it + before the push; the canary, which must not compile, proves the check can fail.""" + texts = {} + for entry in MUTATIONS: + error = _mutated_source_error(entry, texts) + check(f"{entry['label']}: the mutated {entry['file']} still compiles", not error, + error) + check("the canary does not compile, so this check can fail", + _mutated_source_error(CANARY, texts) != "") + + def test_every_named_test_exists_and_has_exactly_one_state(): """A guard is proven, hand-proven or unproven - never two of those, never none. diff --git a/tests/test_nettools_sockets.py b/tests/test_nettools_sockets.py index 14a42be1..eee2b2e9 100644 --- a/tests/test_nettools_sockets.py +++ b/tests/test_nettools_sockets.py @@ -86,6 +86,25 @@ def test_a_missing_psutil_says_how_to_get_it_in_every_language(): f"({text})") +def test_the_note_on_sockets_without_a_program_says_who_may_see_it_here(monkeypatch): + """Windows names the owner to an administrator; Linux names the program only to + root (Ubuntu 26.04: 14 of 14 without one as a user, none under sudo), and the ones + still without one under sudo are held from outside the system (WSL, 4 of 14).""" + from beantester import paths + monkeypatch.setattr(paths, "running_as_root", lambda: False) + check("Windows: the administrator", sk.no_owner_note("win32") == sk.NO_OWNER) + check("Linux, a user: root names them", sk.no_owner_note("linux") == sk.NO_OWNER_LINUX) + monkeypatch.setattr(paths, "running_as_root", lambda: True) + check("Linux, root: held from outside", sk.no_owner_note("linux") == sk.NO_OWNER_ROOT) + for code in LANGS: + with open(os.path.join(LANG_DIR, f"{code}.json"), encoding="utf-8") as f: + texts = json.load(f) + for key in (sk.NO_OWNER, sk.NO_OWNER_LINUX, sk.NO_OWNER_ROOT): + check(f"lang/{code}.json: {key} says how many", "{count}" in texts.get(key, "")) + check(f"lang/{code}.json: a user is told to use sudo", "sudo" in texts[sk.NO_OWNER_LINUX]) + check(f"lang/{code}.json: root is not", "sudo" not in texts[sk.NO_OWNER_ROOT]) + + def test_two_identical_sockets_are_two_rows_with_two_keys(machine): """One process can hold two UDP sockets on the same address and port. The table finds rows by key through a dict, so the same key would send a click on one to diff --git a/tests/test_socket_rows.py b/tests/test_socket_rows.py index 45b337a8..1baa9053 100644 --- a/tests/test_socket_rows.py +++ b/tests/test_socket_rows.py @@ -336,6 +336,22 @@ def test_psutil_rows_speak_the_same_words_as_the_native_ones(monkeypatch): "", None, "", 20), f"({rows[-1]})") +def test_a_socket_in_time_wait_is_owned_by_no_process_on_the_psutil_path_too(monkeypatch): + """Linux keeps no file for a socket in TIME_WAIT, so psutil gives it no PID even + under sudo (WSL, 2026-10-28). Left at None, the Sockets note counted it as another + account's, or under sudo as held from outside the system. It is PID 0, as the + native path and the help say; an unknown owner in any other state stays unknown.""" + fake = FakePsutil( + tcp=[_Conn(_Addr("10.0.0.2", 50000), _Addr("10.0.0.9", 443), "TIME_WAIT", None), + _Conn(_Addr("10.0.0.2", 50001), _Addr("10.0.0.9", 443), "ESTABLISHED", None), + _Conn(_Addr("10.0.0.2", 50002), _Addr("10.0.0.9", 443), "TIME_WAIT", 0)], + udp=[_Conn(_Addr("0.0.0.0", 5353), (), "NONE", None)]) + monkeypatch.setitem(sys.modules, "psutil", fake) + pids = [row.pid for row in portmap._psutil_socket_rows()] + check("TIME_WAIT: PID 0, from None and from 0 alike; the rest stay unknown", + pids == [0, None, 0, None], f"({pids})") + + def test_a_table_nobody_may_read_is_said_to_be_one(monkeypatch): """An empty list would read as "no sockets" - a claim about the machine.""" monkeypatch.setitem(sys.modules, "psutil", FakePsutil(deny=True)) diff --git a/tests/test_summary_repro_views.py b/tests/test_summary_repro_views.py index 65e45c66..b6d68353 100644 --- a/tests/test_summary_repro_views.py +++ b/tests/test_summary_repro_views.py @@ -128,15 +128,59 @@ def test_settings_to_cli_prefix_and_name(): ``paths.is_frozen()``), not from a hard-coded string. """ from beantester import build_arg_parser, settings_to_cli_string - from beantester.appinfo import EXE_NAME, LAUNCHER + from beantester.appinfo import EXE_NAME, LAUNCHER, command_name cmd = settings_to_cli_string({"loss": 5}, seed=123) - check("cli string: run-from-sources prefix", - cmd.startswith(f"python {LAUNCHER}"), f"({cmd[:40]})") + check("cli string: run-from-sources prefix, as this system runs it", + cmd.startswith(command_name() + " ") and LAUNCHER in cmd.split(), f"({cmd[:60]})") check("cli: prog names this build", build_arg_parser().prog in (LAUNCHER, EXE_NAME), f"({build_arg_parser().prog})") +def test_the_command_on_linux_is_the_one_its_readme_runs(): + """Ubuntu 26.04 has no ``python`` (only ``python3``), a capture needs root and + pydivert lives in the virtual environment: the window copied a command that + ended in "command not found" (2026-10-27). It is the README's now, and names no + path from the machine - a report carrying it is shared.""" + from beantester.appinfo import LAUNCHER, linux_command + checkout, home = "/u/ana/bnt", "/u/ana" + say = lambda exe, simulate=False, home=home: linux_command(simulate, exe, checkout, home) # noqa: E731 + check("the venv in the checkout, a capture: sudo and -B, as the README", + say("/u/ana/bnt/.venv/bin/python") == f"sudo .venv/bin/python -B {LAUNCHER}", + f"({say('/u/ana/bnt/.venv/bin/python')})") + check("a simulation needs no root", + say("/u/ana/bnt/.venv/bin/python", True) == f".venv/bin/python {LAUNCHER}") + check("a venv in the person's home: from ~, no account name", + say("/u/ana/.venvs/bnt/bin/python") == f"sudo ~/.venvs/bnt/bin/python -B {LAUNCHER}") + check("anywhere else: python3, never a path", + say("/opt/py/bin/python3.15") == f"sudo python3 -B {LAUNCHER}") + check("a folder that only starts like the checkout is not inside it", + say("/u/ana/bnt-old/bin/python", home="/srv") == f"sudo python3 -B {LAUNCHER}") + check("a space is quoted, ~ is left for the shell", + say("/u/ana/my envs/bin/python", True) == f"~/'my envs/bin/python' {LAUNCHER}") + check("no home known: python3", say("/x/bin/python", True, "") == f"python3 {LAUNCHER}") + + +def test_the_command_follows_the_system_it_runs_on(monkeypatch): + """Windows from source keeps ``python bean_network_tester.py``; Linux asks + ``linux_command``, with root only for a capture.""" + import sys + + from beantester import appinfo, paths + from beantester.repro import settings_to_cli_string + monkeypatch.setattr(paths, "is_frozen", lambda: False) + monkeypatch.setattr(sys, "platform", "win32") + check("Windows", appinfo.command_name() == f"python {appinfo.LAUNCHER}") + monkeypatch.setattr(sys, "platform", "linux") + monkeypatch.setattr(paths, "running_as_root", lambda: False) # asks geteuid, not on Windows + capture = settings_to_cli_string({"loss": 5}, seed=1) + check("Linux, a capture: under sudo", capture.startswith("sudo ") and " -B " in capture + and f" {appinfo.LAUNCHER} --" in capture, f"({capture})") + simulated = settings_to_cli_string({"loss": 5}, seed=1, simulate=True) + check("Linux, a simulation: no sudo", not simulated.startswith("sudo") + and "--simulate" in simulated, f"({simulated})") + + def test_build_repro_report(): from beantester import DEFAULT_SETTINGS, build_repro_report sh = BeanEngine(); sh.set_seed(777) @@ -147,7 +191,7 @@ def test_build_repro_report(): settings = dict(DEFAULT_SETTINGS, loss=20, seed=777) rep = build_repro_report(sh, settings) sh.stop() - ok = (rep["seed"] == 777 and rep["cli_command"].startswith("python bean_network_tester.py") + ok = (rep["seed"] == 777 and "bean_network_tester.py" in rep["cli_command"].split() and "--seed 777" in rep["cli_command"] and "--loss 20" in rep["cli_command"] and "effective_loss_pct" in rep["metrics"] and any(e["type"] == "BUG" for e in rep["events"]) @@ -244,7 +288,7 @@ def test_save_repro_report_roundtrip(): f"({sorted(rep)})") check("repro file: tool = BeanNetworkTester", rep["tool"] == "BeanNetworkTester") check("repro file: cli_command uses the new name", - rep["cli_command"].startswith("python bean_network_tester.py")) + "bean_network_tester.py" in rep["cli_command"].split(), f"({rep['cli_command']})") check("repro file: metrics in English", {"packets", "downloaded_mb", "uploaded_mb", "total_mb"} <= set(rep["metrics"])) @@ -513,6 +557,8 @@ def test_the_session_command_takes_the_scenario_and_the_stand_in_from_the_engine the same run; the scenario reached neither (external review, P1-3). Both are now read off the session itself, by one function every caller uses. """ + import sys + from beantester import DEFAULT_SETTINGS, build_repro_report from beantester.repro import session_command from beantester.scenario import Scenario @@ -530,8 +576,9 @@ def test_the_session_command_takes_the_scenario_and_the_stand_in_from_the_engine report = build_repro_report(eng, settings) finally: eng.stop() + quoted = '"my scenarios/lte.json"' if sys.platform == "win32" else "'my scenarios/lte.json'" check("session command: the scenario, as it was named, quoted for its space", - '--scenario "my scenarios/lte.json" --loop' in during, f"({during})") + f"--scenario {quoted} --loop" in during, f"({during})") check("session command: a stand-in driver is --simulate", "--simulate" in during, f"({during})") check("session command: the report carries the same command", @@ -632,3 +679,49 @@ def pasted(values): check(f"shell: {sent!r} arrives unchanged", back == sent, f"({back!r})") check("shell: the known limit - cmd still expands %NAME% inside quotes", pasted(["re:a%BNT_PROBE_VAR%b"]) == ["re:aEXPANDEDb"]) + + +def test_the_command_survives_bash_with_history_expansion_on(tmp_path, monkeypatch): + """On Linux the command is pasted into bash; what arrives is what was meant. + + The cmd.exe spelling, double quotes, is not enough there: bash still expands + ``$( )``, ``$NAME`` and a backtick inside them, and at a prompt, where history + expansion is on, the ``!`` of ``chrome,!chromedriver`` refused the line ("event + not found") or put an earlier command in its place (measured 2026-10-28, WSL + Ubuntu). The script turns history expansion on, as a prompt has it, and runs the + command ``settings_to_cli_string`` builds into a real program's argv. + """ + import json + import shlex + import shutil + import subprocess + import sys + + import pytest + + bash = shutil.which("bash") + if sys.platform == "win32" or bash is None: + pytest.skip("bash on Linux is the shell under test; cmd.exe has its own test") + from beantester import repro + + echo = tmp_path / "argv_echo.py" + echo.write_text("import json, sys\nprint(json.dumps(sys.argv[1:]))\n", encoding="utf-8") + monkeypatch.setattr(repro, "command_name", lambda simulate=False: " ".join( + shlex.quote(part) for part in (sys.executable, str(echo)))) + env = {name: value for name, value in os.environ.items() if name != "BASH_ENV"} + env["BNT_PROBE_VAR"] = "EXPANDED" + values = ["chrome,!chromedriver", "10.0.0.1-10.0.0.50,!10.0.0.7", "a,!echo b", + "re:^edge", "re:(a|b)$", "$(echo EXPANDED)", "$BNT_PROBE_VAR", "x`echo y`z", + "it's", 'a"b', "a b", "re:\\d+", "re:\\\\d", "end\\", "~/x.json", "*.json"] + script = tmp_path / "pasted.sh" + # One command each, after one earlier command for `!echo` to find in the history. + for value in values: + settings = {"target": value} + command = repro.settings_to_cli_string(settings) + script.write_text(f"set -o history -o histexpand\necho earlier\n{command}\n", + encoding="utf-8") + done = subprocess.run([bash, str(script)], capture_output=True, text=True, env=env) + last = (done.stdout.splitlines() or [""])[-1] + back = json.loads(last) if last.startswith("[") else None + check(f"bash: {value!r} arrives unchanged", back == repro.settings_to_cli(settings), + f"({command!r} -> {done.stdout!r} {done.stderr!r})") diff --git a/tests/test_toolbox.py b/tests/test_toolbox.py index 0e97e2f9..84929f1c 100644 --- a/tests/test_toolbox.py +++ b/tests/test_toolbox.py @@ -952,11 +952,15 @@ def test_the_table_says_why_it_is_empty_and_what_its_rows_are_missing(): run_gui(SOCK + textwrap.dedent(""" ROWS.append(SocketRow("TCP", 4, "10.0.0.2", 9000, "", None, "LISTEN", None)) failed_tables.append("udp/v6") + # the words this system says (``no_owner_note``), stood in so both runners + # see the panel ask for them rather than say Windows' everywhere + from beantester.nettools import sockets as sockets_logic + sockets_logic.no_owner_note = lambda: sockets_logic.NO_OWNER_ROOT panel = open_sockets() settle(panel) note = panel.note.cget("text") assert T("tools.sockets.note_failed", tables="udp/v6") in note, note - assert T("tools.sockets.note_no_pid", count=1) in note, note + assert T(sockets_logic.NO_OWNER_ROOT, count=1) in note, note search(panel, "state:closing") settle(panel) assert panel.table._empty_text == "tools.sockets.empty_match" diff --git a/tests/test_virtual_tables.py b/tests/test_virtual_tables.py index afe4d1dd..35bd3f9d 100644 --- a/tests/test_virtual_tables.py +++ b/tests/test_virtual_tables.py @@ -170,6 +170,59 @@ def test_a_width_the_user_dragged_survives_a_fit_but_hiding_a_column_re_fits(): """) +def test_a_column_is_born_as_wide_as_the_words_it_can_show(): + """``make_room``: the widest text a column can show sizes it, in the window's + language. A hand-counted ``min_chars`` went stale when a word grew: Linux's + "Ustawienia proxy" and Windows' "jeszcze bez pomiaru" were both cut (real Tk, + 2026-10-27). The two Tools lists that show such words declare them, and each of + their columns is at least as wide as the widest of its words.""" + run_gui(""" + from tkinter import ttk + from beantester.gui import scaling + from beantester.gui.widgets import SortableTree, make_room + from beantester.gui.toolbox import adapters as adapters_panel + from beantester.gui.toolbox import tcphealth as health_panel + from beantester.gui.toolbox.details import say + + long = "a word far longer than its header" + table = SortableTree(ttk.Frame(root), {"a": "conns.pid", "b": "conns.pid"}) + make_room(table, {"a": [long, "short"]}) + assert int(table.tree.column("a", "width")) == table._natural["a"], table._natural + assert table._natural["a"] >= scaling.cell_width(long), table._natural + assert table._natural["a"] > table._natural["b"], table._natural + + app.select_page("tools") + for sub, module in (("tcphealth", health_panel), ("adapters", adapters_panel)): + app.pages["tools"].select(sub) + panel = app.pages["tools"].panels[sub] + for column, said in module.WORDS.items(): + widest = max(said, key=lambda w: scaling.cell_width(say(w))) + need = scaling.cell_width(say(widest)) + assert panel.table._natural[column] >= need, (sub, column, say(widest), + panel.table._natural[column], need) + # ...and the words that were cut are among them + said = {say(w) for w in health_panel.WORDS["rtt"]} | \\ + {say(w) for w in adapters_panel.WORDS["kind"]} + assert {"jeszcze bez pomiaru", "Ustawienia proxy"} <= said, said + """, lang="pl") + + +def test_the_empty_note_wraps_inside_its_table(): + """Placed at its own width, a note longer than the table was cut by the frame on + both sides: TCP health's Polish one, 905 px in an 891 px table on Linux (real + Tk, 2026-10-27). It wraps at a share of the table's width, centred.""" + run_gui(""" + from tkinter import ttk + from beantester.gui.widgets.sortable_tree import NOTE_SHARE, SortableTree + table = SortableTree(ttk.Frame(root), {"a": "conns.pid"}, empty_text="conns.pid") + table._on_configure() + note = table._empty_note + wrap = note.cget("wraplength") + assert wrap == int(table.tree.winfo_width() * NOTE_SHARE), (wrap, table.tree.winfo_width()) + assert note.cget("justify") == "center", note.cget("justify") + """) + + def test_the_widget_never_holds_more_than_a_viewport(): """100 000 model rows must not become 100 000 Tcl items.""" run_gui(""" diff --git a/tools/ci_gui_render.py b/tools/ci_gui_render.py index fee28c4e..d6b8a72f 100644 --- a/tools/ci_gui_render.py +++ b/tools/ci_gui_render.py @@ -242,6 +242,128 @@ def _tab_check_can_fail(root): window.destroy() +# -- the tables that cannot scroll sideways ------------------------------------ # +# Such a table has nowhere to put a column past its right edge: Tk cuts it off, +# header and all. Measured 2026-10-27 on real Tk: TCP health's list asked 699 to 716 +# px of a 691 px table at the old 760 px window, in every language, on Windows and +# on Linux, and nothing here looked at a table. Its columns are born at their +# natural width and only ever WIDENED to fill the table +# (``widgets.sortable_tree.fitted_widths``), so a row of columns wider than the table +# is one that did not fit at birth - and the one the person sees. +def _shown_columns(tree): + """The columns ``tree`` draws: its ``displaycolumns``, or all of them for ``#all``.""" + shown = tree.cget("displaycolumns") + shown = (shown,) if isinstance(shown, str) else tuple(shown) + return tuple(tree.cget("columns")) if shown in ((), ("#all",)) else shown + + +def _cut_tables(root): + """Every table on screen that cannot scroll sideways and whose columns run past + its edge: (headers, the columns' width, the table's).""" + found = [] + for widget in _walk(root): + try: + if widget.winfo_class() != "Treeview" or not widget.winfo_ismapped(): + continue + if str(widget.cget("xscrollcommand")): + continue # it scrolls: a wide row is reachable + shown = _shown_columns(widget) + need = sum(int(widget.column(column, "width")) for column in shown) + got = widget.winfo_width() + headers = tuple(str(widget.heading(column, "text")) for column in shown) + except tk.TclError: + continue + if got > 1 and need > got + TOL: + found.append((headers, need, got)) + return found + + +def _table_check_can_fail(root): + """What is wrong with the table check itself, measured on a table made to fail: + one too wide for its window must be reported, the same one in a window wide + enough must not, and neither must a table that scrolls sideways.""" + window = tk.Toplevel(root) + try: + columns = ("a", "b", "c") + tree = ttk.Treeview(window, columns=columns, show="headings") + scrolling = ttk.Treeview(window, columns=columns, show="headings", + xscrollcommand=lambda *_: None) + for table in (tree, scrolling): + for column in columns: + table.heading(column, text=column) + table.column(column, width=scaling.scaled(200), stretch=False) + table.pack(fill="x") + need = 3 * scaling.scaled(200) + problems = [] + for width, should_see in ((need // 2, True), (need + 40, False)): + window.geometry(f"{width}x{scaling.scaled(240)}") + window.update_idletasks() + window.update() + seen = len(_cut_tables(window)) + if seen != (1 if should_see else 0): + problems.append(f"the table check saw {seen} cut table(s) at {width} px, " + f"where {1 if should_see else 0} is cut (they ask {need})") + return problems + finally: + window.destroy() + + +# -- what is placed rather than packed ----------------------------------------- # +# A widget put in with ``place`` takes its own width, and its parent cuts whatever +# lies past its edges - the button and label checks never see it, because the widget +# got all it asked for. Measured 2026-10-27: the TCP health list's empty note was +# 905 px in an 891 px table on Linux in Polish and lost letters on both sides. The +# tables' empty notes are the only placed widgets today. +def _overflowing_placed(root): + """Every placed widget on screen that runs past its parent: (text, width, room).""" + found = [] + for widget in _walk(root): + try: + if not widget.winfo_ismapped() or widget.winfo_manager() != "place": + continue + room = widget.nametowidget(widget.winfo_parent()).winfo_width() + left, width = widget.winfo_x(), widget.winfo_width() + text = _text(widget).strip() + except tk.TclError: + continue + if room > 1 and (left < -TOL or left + width > room + TOL): + found.append((text, width, room)) + return found + + +def _placed_check_can_fail(root): + """What is wrong with the placed-widget check itself: a label placed in a frame + too narrow for it must be reported, and the same label made short must not.""" + window = tk.Toplevel(root) + try: + frame = ttk.Frame(window, width=scaling.scaled(200), height=scaling.scaled(60)) + frame.pack() + label = ttk.Label(frame, text="a placed label much wider than the frame it is in") + label.place(relx=0.5, rely=0.5, anchor="center") + problems = [] + for text, should_see in ((label.cget("text"), True), ("short", False)): + label.config(text=text) + window.update_idletasks() + window.update() + if bool(_overflowing_placed(window)) != should_see: + problems.append(f"the placed-widget check {'missed' if should_see else 'invented'} " + f"a label past its frame ({text!r})") + return problems + finally: + window.destroy() + + +def _say_cut(code, verdict, tables, placed): + """The log lines for what runs past an edge it cannot scroll to.""" + for headers, req, got in tables: + print(f" [{code}] {verdict} TABLE - its last columns are past its edge, and it " + f"cannot scroll there (columns {req} px, table {got}): " + f"{', '.join(map(repr, headers))}") + for text, width, room in placed: + print(f" [{code}] {verdict} PLACED WIDGET - wider than its parent, which cuts it " + f"(width={width} room={room}): {text!r}") + + PAINT_OPTIONS =("background", "foreground", "bordercolor", "lightcolor", "darkcolor") @@ -422,8 +544,10 @@ def check_language(code): face = tkfont.Font(root=root, font=ttk.Style(root).lookup("TNotebook.Tab", "font")) print(f" [{code}] tab font {face.actual('family')} {face.actual('size')}") selftest = _tab_check_can_fail(root) + selftest += _table_check_can_fail(root) + selftest += _placed_check_can_fail(root) - buttons, labels, cut, squeezed = [], [], [], [] + buttons, labels, cut, squeezed, tables, placed = [], [], [], [], [], [] styles = _declared_styles() def scan(): @@ -432,6 +556,8 @@ def scan(): labels.extend(l) cut.extend(c) squeezed.extend(_squeezed_tabs(root)) + tables.extend(_cut_tables(root)) + placed.extend(_overflowing_placed(root)) walked = walk_surfaces(app, root, scan) # Named in the log on purpose: a guard whose coverage nobody can see is a @@ -463,6 +589,8 @@ def scan(): for row, req, got in squeezed: widest[row] = max(widest.get(row, (0, got)), (req, got)) squeezed = sorted((row, req, got) for row, (req, got) in widest.items()) + tables = sorted(set(tables)) + placed = sorted(set(placed)) for text, req, got in labels: print(f" [{code}] note: label narrower than requested " f"(req={req} got={got}): {text!r}") @@ -480,20 +608,20 @@ def scan(): for row, req, got in squeezed: print(f" [{code}] {'unmeasured' if drawable is False else 'SQUEEZED'} TAB ROW - " f"every label in it is cut (req={req} got={got}): {', '.join(map(repr, row))}") + _say_cut(code, "unmeasured" if drawable is False else "CUT", tables, placed) for problem in selftest: - print(f" [{code}] TAB CHECK BROKEN: {problem}") + print(f" [{code}] CHECK BROKEN: {problem}") measured = drawable is not False ok = not twins and not selftest and (not measured or (not buttons and not cut + and not tables + and not placed and not squeezed)) - problems = [] - if buttons and measured: - problems.append(f"{len(buttons)} clipped button(s)") - if cut and measured: - problems.append(f"{len(cut)} truncated label(s)") - if squeezed and measured: - problems.append(f"{len(squeezed)} squeezed tab row(s)") + found = ((buttons, "clipped button(s)"), (cut, "truncated label(s)"), + (squeezed, "squeezed tab row(s)"), (tables, "cut table(s)"), + (placed, "cut placed widget(s)")) + problems = [f"{len(what)} {kind}" for what, kind in found if what and measured] if selftest: - problems.append("the tab check cannot tell a squeezed row from one that fits") + problems.append("a check cannot tell a row that does not fit from one that does") if twins: problems.append(f"{len(twins)} style(s) where focus looks like hover") state = "OK" if measured else "built, layout NOT measured (no font)" diff --git a/tools/mutate.py b/tools/mutate.py index 3143eaba..3e4615f7 100644 --- a/tools/mutate.py +++ b/tools/mutate.py @@ -31,6 +31,10 @@ 4. **The canary.** One entry is deliberately broken syntax and MUST come back as BROKEN. Without it, a runner whose subprocess call is misconfigured reports "everything caught" and the whole run is a lie. +5. **Any OTHER entry that comes back BROKEN fails the run.** A mutation that does + not compile reddens every test, whatever it guards, so it proves nothing. A + `--changed` run passed with one (2026-10-27: "BROKEN: 1 caught: 351", exit 0), + because only the full run counted BROKENs, and only to find the canary. Usage ----- @@ -171,20 +175,34 @@ def main(argv): return 0 elif not needle: entries = entries + [CANARY] + counts, broken, canary_fired = _run(entries) + # The canary only rides along on a FULL run. A filtered run (a label, or + # --changed) has none, and demanding one there would make every pull request + # fail for the shape of the run rather than for its result. + canary_ok = needle or ref is not None or canary_fired + if not canary_ok: + print("CANARY DID NOT FIRE - this whole run proves nothing") + if broken: + print("BROKEN ENTRIES - their mutated code does not compile, so they prove " + "nothing: " + "; ".join(broken)) + return 0 if counts.get("SURVIVED", 0) == 0 and canary_ok and not broken else 1 + + +def _run(entries): + """Apply each entry: ``(counts per state, labels of entries that came back + BROKEN, whether the canary did)``. The canary is told apart by identity.""" width = max(len(m["label"]) for m in entries) - counts = {} + counts, broken, canary_fired = {}, [], False for entry in entries: state, detail = apply_one(entry) counts[state] = counts.get(state, 0) + 1 + if state == "BROKEN" and entry is CANARY: + canary_fired = True + elif state == "BROKEN": + broken.append(entry["label"]) print("%-*s %-9s %s" % (width, entry["label"], state, detail)) print("\n" + " ".join("%s: %d" % kv for kv in sorted(counts.items()))) - # The canary only rides along on a FULL run. A filtered run (a label, or - # --changed) has none, and demanding one there would make every pull request - # fail for the shape of the run rather than for its result. - canary_ok = needle or ref is not None or counts.get("BROKEN") == 1 - if not canary_ok: - print("CANARY DID NOT FIRE - this whole run proves nothing") - return 0 if counts.get("SURVIVED", 0) == 0 and canary_ok else 1 + return counts, broken, canary_fired if __name__ == "__main__":