diff --git a/CHANGELOG.md b/CHANGELOG.md index 0664db1..b6d3c6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,20 @@ The format follows [Keep a Changelog](https://keepachangelog.com/); versions fol ### Fixed +- **Editing "Target process" during a session changes nothing until you click "Apply + changes".** The field used to reach the running session on its own. Clearing it to type + a new name, or stopping halfway through an expression such as `re:^fire(`, switched + process targeting off, so every connection on the computer was impaired, while the + note under the field said traffic was not being impaired. The field now works like + every other field: "Apply changes" lights up, and the session keeps its target until + you click it. + +- **The note under "Target process" tells the truth when targeting cannot be used.** + When an applied target cannot narrow the session, for example because psutil is not + installed, every connection in the traffic filter is impaired, and the note now says + so. It used to say that no traffic was being impaired. The note also goes away when + the session stops. + - **The program no longer freezes while it records an internal error.** Writing a crash report used to read the Control page form. If a field held a value the program cannot accept, such as a letter typed into Loss, or if a background task hit an error at the diff --git a/beantester/gui/app.py b/beantester/gui/app.py index 7c55607..86cd0a9 100644 --- a/beantester/gui/app.py +++ b/beantester/gui/app.py @@ -42,9 +42,9 @@ from ..processes import port_process_map from ..repro import save_repro_report, settings_to_cli_string from ..scenario import load_scenario_file -from ..settings import (DEFAULT_SETTINGS, apply_settings, apply_targeting, - load_config_file, non_profile_active, save_config_file, - settings_from_raw, warn_if_unbounded) +from ..settings import (DEFAULT_SETTINGS, apply_settings, load_config_file, + non_profile_active, save_config_file, settings_from_raw, + warn_if_unbounded) from ..summary import settings_summary from ..utils import number_string from . import crash as gui_crash @@ -131,7 +131,9 @@ def __init__(self, root): self._ui_errors_shown = set() self.engine = BeanEngine(self.log) self.running = False - self._applied_target = None # last expression pushed to the engine + # The target the user last APPLIED (START / "Apply changes"), not what the + # field holds now. Only the banner reads it - see _refresh_target_verdict. + self._applied_target = "" # Start/stop run their blocking parts (WinDivert driver load ~0.5-1 s, and # the worker-thread joins on stop) OFF the UI thread, so the window never # freezes. The worker leaves its result in _ui_queue and the main thread @@ -142,11 +144,6 @@ def __init__(self, root): self._transition_thread = None self._pending_start_settings = None self._closing = False - # Main-thread snapshot of the targeting fields. The refresher thread used - # to call tk Variable.get() directly - a Tcl call from a worker thread, - # which is exactly the kind of thing that makes Tk hang or crash at - # random (and then the process lingers, holding the WinDivert driver). - self._target_expr = "" # The refresher thread's verdict on the target ("matches nothing", or ""), # handed to the main thread the same way log lines are: the thread writes a # plain string, _tick() puts it on the widget. It used to call @@ -1194,6 +1191,7 @@ def apply_if_running(self, *_, announce=False): self.log(f"{T('log.error')}: {e}") return apply_settings(self.engine, s, self.log) + self._applied_target = str(s.get("target", "")).strip() # A session can BECOME unbounded: clear the target, press "Apply changes", # and from that moment everything on the machine is in scope. Warning only # at START would mean the one path that reaches this state in silence is @@ -1212,18 +1210,6 @@ def reset_now_click(self): self.engine.reset_now(3.0) self.log(T("log.resetting")) - def _snapshot_target(self): - """Read the target field ON THE MAIN THREAD (tkinter is not thread-safe). - - The refresher thread only ever sees this plain string. - """ - try: - expression = str(self.vars["target"].get()).strip() - except Exception: - expression = "" - self._target_expr = expression - return self._target_expr - def set_target_warning(self, text): """Show (or clear) the "targeting is doing nothing" banner. @@ -1282,41 +1268,35 @@ def _drain_target_warning(self): self._shown_target_warning = text self.set_target_warning(text) - def _refresh_target(self, force=False): - """Keep the engine's target in step with the field, and report what it caught. - - This used to run on a 2 s background loop (``_target_refresher``, removed). - Every pass called ``apply_targeting``, which resolved the port set - SYNCHRONOUSLY - four syscalls and a psutil walk - on a thread nobody - watched. Worse, the loop was never joined while ``_finish_start`` spawned a - new one on every start, so a STOP followed by a START inside its sleep left - the old one running as well: one extra permanent scanner per fast restart. - - Keeping the port set fresh is ``target_resolver``'s job now. What is left - here is cheap and runs on the main thread from ``_tick``: apply the - expression when the USER changed it, then read the verdict. - - NO WIDGET IS TOUCHED HERE - the verdict goes into a plain field and - ``_drain_target_warning`` renders it. Deliberately kept that way: it is the - shape that stops a Tcl call ever leaving the main thread, whoever calls this - next (convention 26). + def _refresh_target_verdict(self): + """Say what the APPLIED target catches. It reads; it never applies anything. + + The target reaches the engine the way every other field does: START and + "Apply changes", through ``apply_settings`` - validated, announced, and + followed by the unbounded-impairment warning (convention 15, "nothing + applies itself"). Until 2026-09-28 this ran from every tick and pushed the + RAW field to the engine whenever it changed. Clearing the field to type a + new name, or a half-typed ``re:^fire(``, switched targeting off - every + connection in the filter impaired - while the banner said the opposite. + + The verdict follows the ENGINE, not the field: a scenario step may change + the target too, and what is typed but not applied changes nothing yet (the + Apply button says so). Keeping the port set fresh is ``target_resolver``'s + job. + + NO WIDGET IS TOUCHED HERE and no tk variable is read - the verdict goes into + a plain field and ``_drain_target_warning`` renders it. It is the shape that + stops a Tcl call ever leaving the main thread, whoever calls this next + (convention 26). """ - expression = self._target_expr - if force or expression != self._applied_target: - self._applied_target = expression - if not expression: - self.engine.set_target(False) - else: - # One shared implementation (settings.apply_targeting) compiles the - # expression and points the engine at it, so the GUI and - # apply_settings can never drift apart. - apply_targeting(self.engine, expression, self.log, announce=force) - if not expression: - self._pending_target_warning = "" - return targeting = self.engine.targeting() if targeting is None: - self._pending_target_warning = T("fields.target_no_match") + # Nothing narrows the session. That is only news when a target WAS + # applied - an expression that narrows nothing, no psutil, or a regex + # refused at apply time - and then EVERY connection is impaired. The + # banner used to say "traffic is NOT being impaired" here. + self._pending_target_warning = ( + T("fields.target_all_traffic") if self._applied_target else "") return if targeting.refreshes == 0: if self.engine.is_running(): @@ -1404,13 +1384,12 @@ def _finish_start(self, err): if self._scenario is not None: self._scenario.loop = self.loop_var.get() self.engine.start_scenario(self._scenario, s, log=self.log) - self._snapshot_target() note = scope.capture_scope_note(s, self.engine.capture_narrowed()) if note: self.log(T(note)) - # No refresher thread any more: _tick applies a changed expression and the - # engine's resolver keeps the port set fresh (see _refresh_target). - self._applied_target = None # re-apply once, now that the engine is up + # What START applied (the worker ran apply_settings with `s`), not what + # the field holds now - an edit made while the driver loaded is unapplied. + self._applied_target = str(s.get("target", "")).strip() self._sync_running_ui() def _stop(self): @@ -1793,11 +1772,12 @@ def _tick(self): self._drain_target_warning() # render the target verdict (main thread) self._drain_engine_warning() # "the tool itself is dropping packets" self._sample() - self._snapshot_target() # main-thread read of the target field if self.running: - # Cheap now: applies only when the expression changed, and the - # resolving happens on the engine's resolver thread. - self._refresh_target() + # Reads the verdict on what START / "Apply changes" applied and + # never applies anything itself (convention 15, see the method). + self._refresh_target_verdict() + else: + self._pending_target_warning = "" # nothing is impaired when stopped if self._transition is None and self.running and not self.engine.is_running(): self._on_engine_stopped() # deadline reached / worker fault if self._visible(): diff --git a/lang/en.json b/lang/en.json index 025c9bf..802c7a8 100644 --- a/lang/en.json +++ b/lang/en.json @@ -235,6 +235,7 @@ "fields.spike_prob": "Spike chance:", "fields.spike_prob_up": "Spike chance up:", "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_no_match": "No running process matches this target - traffic is NOT being impaired.", diff --git a/lang/pl.json b/lang/pl.json index e14f65a..01ca88a 100644 --- a/lang/pl.json +++ b/lang/pl.json @@ -235,6 +235,7 @@ "fields.spike_prob": "Szansa skoku:", "fields.spike_prob_up": "Szansa skoku w górę:", "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_no_match": "Żaden działający proces nie pasuje do tego celu - ruch NIE jest modyfikowany.", diff --git a/lang/zh.json b/lang/zh.json index 0d9335a..7651d92 100644 --- a/lang/zh.json +++ b/lang/zh.json @@ -235,6 +235,7 @@ "fields.spike_prob": "尖峰概率:", "fields.spike_prob_up": "上传尖峰概率:", "fields.syn_drop": "丢弃 TCP SYN:", + "fields.target_all_traffic": "进程定位未生效。流量过滤器中的所有连接都在受到弱网影响。", "fields.target_dest": "目标地址", "fields.target_example": "例如 chrome.exe、12345、re:^fire", "fields.target_no_match": "没有正在运行的进程符合此目标。流量当前不会受到弱网影响。", diff --git a/tests/test_code_hygiene.py b/tests/test_code_hygiene.py index 5d570f3..8f454c3 100644 --- a/tests/test_code_hygiene.py +++ b/tests/test_code_hygiene.py @@ -397,7 +397,7 @@ def test_the_known_unused_list_only_ever_shrinks(): # ratchet rather than a list of the usual suspects: a NEW file full of silent # handlers cannot slip in by simply not being mentioned. SILENT_BROAD_HANDLERS = { - "gui/app.py": 13, + "gui/app.py": 12, # 12 on 2026-09-06, then seven were dealt with in the same change - the file # this inventory was built to look at first, because it is on the targeting # path. The five left are per-PID lookups (`_make_native`, the two halves of diff --git a/tests/test_code_shape.py b/tests/test_code_shape.py index d476067..1083d6c 100644 --- a/tests/test_code_shape.py +++ b/tests/test_code_shape.py @@ -100,7 +100,10 @@ # ceiling exactly, so the two CSV exports moved to `gui/csv_export.py` instead of the # number moving up. The crowd band below was re-measured after the drop (`engine.py` # is 779, still clear of it) - lowering a ceiling tightens that band too. -FILE_CEILING = 1166 # beantester/gui/app.py +# Lowered 2026-09-28 from 1166: the target field stopped being applied from the +# tick (convention 15), which took `_snapshot_target` and half of the old +# `_refresh_target` out of `app.py`. +FILE_CEILING = 1150 # 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 @@ -790,11 +793,11 @@ def test_the_strictly_typed_modules_only_ever_grow(): # in this file: down is routine, up is the owner's decision, and the numbers must # BE the measurement rather than sit above it (two tests below enforce that, the # same pair that guards the ceilings). -CLASS_METHOD_CEILING = 96 # gui/app.py::App -CLASS_ATTR_CEILING = 80 # gui/app.py::App -# The crowd counts, on the same 70% band as the sizes. Methods: App (96) and -# BeanEngine (69) against a band of 67.2. Attributes: App (80) and BeanCore (57) -# against a band of 56.0 - and BeanCore is the interesting one, because it is a +CLASS_METHOD_CEILING = 95 # gui/app.py::App (96 until 2026-09-28: _snapshot_target) +CLASS_ATTR_CEILING = 79 # gui/app.py::App (80 until 2026-09-28: _target_expr) +# The crowd counts, on the same 70% band as the sizes. Methods: App (95) and +# BeanEngine (69) against a band of 66.5. Attributes: App (79) and BeanCore (57) +# against a band of 55.3 - and BeanCore is the interesting one, because it is a # 485-line file that no size ratchet has ever had a reason to look at. Fifty-seven # attributes is what a decision core with twelve pipeline steps accumulates. CLASSES_NEAR_METHOD_CEILING = 2 # App, BeanEngine diff --git a/tests/test_failsafe.py b/tests/test_failsafe.py index d7f6e16..b731cef 100644 --- a/tests/test_failsafe.py +++ b/tests/test_failsafe.py @@ -951,32 +951,28 @@ def test_the_ui_notices_when_the_engine_stops_itself(): """) -def test_target_syncing_reads_only_the_main_thread_snapshot(): - """``_refresh_target`` works off ``_target_expr``, never off the tk variable. +def test_the_target_verdict_never_reads_the_tk_variable(): + """``_refresh_target_verdict`` works off the engine and the applied target. - The background refresher that used to call this is gone (resolving moved to - ``target_resolver``), but the separation it forced is worth keeping: the - snapshot is taken on the main thread, and everything downstream consumes the - plain string. That is what makes it safe to call this from anywhere later. + It never touches the tk variable: that is what makes it safe to call from + anywhere, and since 2026-09-28 it is also the rule - the field reaches the + engine only through "Apply changes", so the verdict has no business reading it. """ run_gui(""" - app.vars["target"].set("chrome.exe") - assert app._snapshot_target() == "chrome.exe" + from beantester.settings import apply_targeting - # an empty field means "no targeting" - there is no checkbox to tick - app.vars["target"].set(" ") - assert app._snapshot_target() == "" + apply_targeting(app.engine, "chrome.exe", announce=False) + app._applied_target = "chrome.exe" - # from now on the tk variable explodes if anything downstream reads it + # from now on the tk variable explodes if anything reads or writes it class Exploding: def get(self): - raise AssertionError("_refresh_target read the tk variable") + raise AssertionError("the verdict read the tk variable") def set(self, *a): - raise AssertionError("_refresh_target wrote the tk variable") + raise AssertionError("the verdict wrote the tk variable") app.vars["target"] = Exploding() - app._target_expr = "chrome.exe" - app._refresh_target() # consumes the snapshot only + app._refresh_target_verdict() """) diff --git a/tests/test_gui_release_fixes.py b/tests/test_gui_release_fixes.py index e63f103..2e37b4f 100644 --- a/tests/test_gui_release_fixes.py +++ b/tests/test_gui_release_fixes.py @@ -219,27 +219,75 @@ def test_the_window_is_capped_and_cannot_be_maximised(): def test_a_target_that_matches_nothing_says_so_on_the_page(): """A run in which nothing broke looks exactly like a run in which it held up. - ``_refresh_target`` runs on the refresher THREAD, so it only records the - verdict; the banner itself is put on screen by the main thread (``_tick`` -> - ``_drain_target_warning``). The end result the user sees is unchanged. + ``_refresh_target_verdict`` only records the verdict on the APPLIED target; + the banner itself is put on screen by the main thread (``_tick`` -> + ``_drain_target_warning``). """ run_gui(""" - app.vars["target"].set("definitely-no-such-process") - app._snapshot_target() - app._refresh_target(force=True) + from beantester.settings import apply_targeting + + apply_targeting(app.engine, "definitely-no-such-process", announce=False) + app._applied_target = "definitely-no-such-process" + app._refresh_target_verdict() app._drain_target_warning() # what _tick() does on the main thread assert app.target_warning.kw.get("text") == bnt.T("fields.target_no_match") assert app.target_warning.winfo_ismapped() - app.vars["target"].set("") - app._snapshot_target() - app._refresh_target(force=True) + apply_targeting(app.engine, "", announce=False) + app._applied_target = "" + app._refresh_target_verdict() app._drain_target_warning() assert app.target_warning.kw.get("text") == "" assert not app.target_warning.winfo_ismapped() """) +def test_a_target_that_cannot_be_used_says_everything_is_impaired(): + """The banner used to say the OPPOSITE of the truth here. + + A target that was applied but could not be used - no psutil, an expression + that narrows nothing, a regex refused at apply time - leaves the engine with + no targeting at all, so EVERY connection in the filter is impaired. The banner + said "traffic is NOT being impaired". Both ways in (START and "Apply + changes") are covered, and the banner goes away with the session. + """ + run_gui(""" + from beantester.synthetic import SyntheticDivert + + def no_psutil(matcher): + raise ImportError("psutil is not installed") + + app.engine.target_for = no_psutil + real_start = app.engine.start + app.engine.start = (lambda filt, divert=None, duration=0, **kw: + real_start(filt, divert=SyntheticDivert(seed=3), + duration=duration)) + everything = bnt.T("fields.target_all_traffic") + + app.vars["target"].set("chrome.exe") + app._start(); app._settle_transition() + assert app.running and app.engine.targeting() is None + app._tick(); app._tick() # verdict, then render + assert app._pending_target_warning == everything, app._pending_target_warning + assert app.target_warning.kw.get("text") == everything + + app.vars["target"].set("") # applied: nothing was aimed at + app.apply_if_running() + app._tick(); app._tick() # verdict, then render + assert app._pending_target_warning == "", app._pending_target_warning + + app.vars["target"].set("firefox.exe") # applied again, same failure + app.apply_if_running() + app._tick(); app._tick() # verdict, then render + assert app._pending_target_warning == everything, app._pending_target_warning + + app._stop(); app._settle_transition() + app._tick(); app._tick() # verdict, then render + assert app._pending_target_warning == "", "the banner outlived the session" + assert not app.target_warning.winfo_ismapped() + """, allow_faults=("psutil is not installed",)) + + def test_start_only_fields_are_locked_while_a_session_runs(): """EVERY field the registry marks start_only greys out mid-session, on whichever surface renders it. diff --git a/tests/test_gui_state.py b/tests/test_gui_state.py index 9771873..b56e349 100644 --- a/tests/test_gui_state.py +++ b/tests/test_gui_state.py @@ -171,6 +171,10 @@ def test_a_row_action_fills_the_form_and_does_not_reach_a_running_engine(): So this asserts BOTH directions - untouched engine before Apply, changed engine after it - because only the pair distinguishes "did not apply" from "did not work at all". + + 🔴 It TICKS. Until 2026-09-28 it did not, and the tick was exactly where the + target field went to the engine without Apply - so this test stayed green + while the rule it names was broken on every tick of every session. """ run_gui(""" app.running = True @@ -180,12 +184,14 @@ def test_a_row_action_fills_the_form_and_does_not_reach_a_running_engine(): app.set_target_expression("chrome.exe") app.set_destination("10.0.0.7", "443") + assert app._form_changed, "the Apply button must light up instead" + for _ in range(3): + app._tick() # the loop that used to apply the field assert not core.target_active, "targeting reached the engine without Apply" assert not core.dst_active, "destination reached the engine without Apply" s = app._settings_from_widgets() assert s["target"] == "chrome.exe" and s["dst_ip"] == "10.0.0.7" - assert app._form_changed, "the Apply button must light up instead" app.apply_if_running(announce=False) assert core.target_active and core.dst_active, "Apply did not push them" @@ -336,9 +342,9 @@ def test_dialogs_are_in_app_and_translated(): def test_target_verdict_is_recorded_not_rendered_off_the_main_thread(): - """``_refresh_target`` must leave its verdict in a field, never draw it itself. + """``_refresh_target_verdict`` leaves its verdict in a field, never draws it. - ``_refresh_target`` used to call ``set_target_warning`` directly, so + The method used to call ``set_target_warning`` directly, so ``.config()`` / ``.winfo_ismapped()`` / ``.pack()`` ran on a worker thread. On Windows that either hangs Tk - and a hung GUI keeps the WinDivert handle open, which is the one thing FAIL-OPEN exists to prevent - or raises @@ -367,12 +373,14 @@ def spy(*a, _n=name, _o=original, **kw): setattr(banner, name, spy) - # an expression that resolves to no process at all: the branch that raises - # the "your target catches nothing" banner - app.vars["target"].set("no_such_process_anywhere_xyz") - app._snapshot_target() + # an APPLIED expression that resolves to no process at all: the branch that + # raises the "your target catches nothing" banner + from beantester.settings import apply_targeting + apply_targeting(app.engine, "no_such_process_anywhere_xyz", announce=False) + app._applied_target = "no_such_process_anywhere_xyz" - worker = threading.Thread(target=app._refresh_target, name="target-refresher") + worker = threading.Thread(target=app._refresh_target_verdict, + name="target-refresher") worker.start() worker.join(timeout=30) assert not worker.is_alive(), "the refresher thread hung" @@ -395,25 +403,10 @@ def spy(*a, _n=name, _o=original, **kw): """) -def test_a_gui_session_keeps_the_target_banner_honest(): - """The tick loop end to end: apply on change, and report what was matched. - - The GUI no longer runs a refresher thread - `_tick` applies a changed target - expression and the engine's resolver keeps the port set fresh. That rewiring - was verified against a real session (real engine, real resolver, synthetic - traffic) and this pins the behaviour it must keep: - - * a target that matches nothing raises the banner - a run in which nothing - broke looks exactly like a run in which everything held up; - * a target that DOES match takes it back down; - * clearing the field drops targeting altogether; - * none of it stalls the capture. - - The socket table is faked so the test is deterministic and fast. Against the - real one the first resolve costs about 1.7 s on a normal desktop, which is a - measurement worth knowing but not worth spending in every suite run. - """ - run_gui(""" +# A GUI session over a FAKE socket table (one process, `realapp.exe`, owning port +# 5001) and synthetic traffic, plus `settle()`, which ticks for a while. Shared by +# the two target-session tests below, which used to carry a copy each. +_TARGET_SESSION = """ import time from beantester import portmap from beantester.synthetic import SyntheticDivert @@ -449,7 +442,30 @@ def settle(seconds=1.0): while time.monotonic() < end: app._tick() time.sleep(0.02) +""" + +def test_a_gui_session_keeps_the_target_banner_honest(): + """A session end to end: the target changes on Apply, the tick only reports. + + The engine's resolver keeps the port set fresh and `_tick` reads the verdict. + This pins the behaviour a session must keep: + + * a target that matches nothing raises the banner - a run in which nothing + broke looks exactly like a run in which everything held up; + * a target that DOES match takes it back down; + * 🔴 an emptied or half-typed field changes NOTHING until "Apply changes". + Until 2026-09-28 the tick pushed the raw field to the engine: clearing it + to type a new name, or typing `re:^fire(`, switched targeting off - every + connection impaired - and the banner said traffic was NOT impaired; + * applying an empty field drops targeting altogether; + * none of it stalls the capture. + + The socket table is faked so the test is deterministic and fast. Against the + real one the first resolve costs about 1.7 s on a normal desktop, which is a + measurement worth knowing but not worth spending in every suite run. + """ + run_gui(_TARGET_SESSION + """ app._start(); app._settle_transition() assert app.running is True, "the GUI did not start" settle(0.3) @@ -460,6 +476,7 @@ def settle(seconds=1.0): # a target nothing matches: the banner must shout app.vars["target"].set("no_such_process_xyz") + app.apply_if_running() settle(1.0) tg = app.engine.targeting() assert app._applied_target == "no_such_process_xyz", app._applied_target @@ -470,6 +487,7 @@ def settle(seconds=1.0): # a target that DOES own a socket: the banner must come back down app.vars["target"].set("realapp") + app.apply_if_running() settle(1.0) tg = app.engine.targeting() assert tg.expression == "realapp", tg.expression @@ -478,10 +496,26 @@ def settle(seconds=1.0): "a matching target must clear the banner: %r" % app._pending_target_warning assert app._shown_target_warning == "", "the banner was not taken down" - # clearing the field drops targeting entirely + # the user empties the field to type another name, then gets half-way + # through a regex. NEITHER is applied: the engine keeps the target it had. + refused = bnt.T("log.targeting_error") + for typed in ("", "re:^fire("): + app.vars["target"].set(typed) + settle(0.4) + assert app.engine.core.target_active, \\ + "the field %r reached the engine without Apply" % typed + assert app.engine.targeting().expression == "realapp", \\ + app.engine.targeting().expression + assert app._pending_target_warning == "", app._pending_target_warning + assert not any(refused in line for line in app._log_lines), \\ + "a half-typed field was compiled: %r" % app._log_lines[-3:] + assert app._is_dirty(), "the Apply button must say the form differs" + + # applying the empty field drops targeting entirely app.vars["target"].set("") + app.apply_if_running() settle(0.4) - assert app.engine.targeting() is None, "clearing must drop targeting" + assert app.engine.targeting() is None, "applying an empty field must drop targeting" assert app._pending_target_warning == "", "no target, no banner" assert app.engine.stats_snapshot()["seen"] > seen1, "traffic stalled" @@ -503,8 +537,8 @@ def test_a_target_that_dies_mid_session_raises_the_banner_without_being_retyped( tester moves the other end - the field is left alone and the targeted program exits, or the harness restarts it and Windows hands it a new pid. Nothing covered that, and a handoff note had already concluded from reading the code - that the verdict was only taken at session start. It is not: ``_refresh_target`` - re-reads it on every tick. This pins that, because prose is what the project + that the verdict was only taken at session start. It is not: + ``_refresh_target_verdict`` re-reads it on every tick. This pins that, because prose is what the project keeps getting wrong here, and prose is what nothing tests. The recovery half mirrors what was MEASURED against a real capture @@ -513,41 +547,10 @@ def test_a_target_that_dies_mid_session_raises_the_banner_without_being_retyped( come back DOWN on its own too - a warning that stays up after the program is back is the same lie in the other direction. """ - run_gui(""" - import time - from beantester import portmap - from beantester.synthetic import SyntheticDivert - - class FakeTable: - def __init__(self): - self.ports = {5001: 200} - self.info = {200: ("realapp.exe", 1)} - def refresh(self, now=None, force=False): return True - def snapshot(self): return dict(self.ports) - def name_of(self, pid, cheap=False): return self.info.get(pid, ("", None))[0] - def ancestors(self, pid, depth=8): return [] - def warm_names(self): return None - def refresh_if_stale(self, now=None, miss=False): return True - def process_for_port(self, port, now=None, allow_refresh=True): return "" - def pid_for(self, port): return self.ports.get(port) - - table = FakeTable() - portmap.default_table = lambda: table - app.engine._ports = table - - real_start = app.engine.start - app.engine.start = (lambda filt, divert=None, duration=0, **kw: - real_start(filt, divert=SyntheticDivert(seed=21), - duration=duration)) - - def settle(seconds=1.0): - end = time.monotonic() + seconds - while time.monotonic() < end: - app._tick() - time.sleep(0.02) - + run_gui(_TARGET_SESSION + """ app._start(); app._settle_transition() app.vars["target"].set("realapp") + app.apply_if_running() settle(1.0) assert app.engine.targeting().matched is True, "the target never matched" assert app._pending_target_warning == "", app._pending_target_warning diff --git a/tests/test_mutation_registry.py b/tests/test_mutation_registry.py index 89017e0..bec3f46 100644 --- a/tests/test_mutation_registry.py +++ b/tests/test_mutation_registry.py @@ -364,6 +364,54 @@ "new": "", "test": "test_one_window_per_fault_not_one_per_occurrence", }, + { + # The shipped P0: every tick pushed the RAW target field to the engine, so + # an emptied or half-typed field switched targeting off without Apply. + "label": "gui: the tick pushes the target field to the engine again", + "file": "beantester/gui/app.py", + "old": " self._refresh_target_verdict()\n else:", + "new": " self._refresh_target_verdict()\n" + " __import__(\"beantester.settings\", fromlist=[\"x\"])" + ".apply_targeting(self.engine, " + "str(self.vars[\"target\"].get()).strip(), announce=False)\n" + " else:", + "test": "test_a_row_action_fills_the_form_and_does_not_reach_a_running_engine", + }, + { + # Back to the banner that said the opposite of the truth: a target that + # could not be used leaves EVERY connection impaired, not none. + "label": "gui: an unusable target is reported as impairing nothing", + "file": "beantester/gui/app.py", + "old": 'T("fields.target_all_traffic") if self._applied_target else "")', + "new": 'T("fields.target_no_match") if self._applied_target else "")', + "test": "test_a_target_that_cannot_be_used_says_everything_is_impaired", + }, + { + "label": "gui: START forgets which target it applied", + "file": "beantester/gui/app.py", + "old": " self._applied_target = str(s.get(\"target\", \"\")).strip()\n" + " self._sync_running_ui()", + "new": " self._applied_target = \"\"\n" + " self._sync_running_ui()", + "test": "test_a_target_that_cannot_be_used_says_everything_is_impaired", + }, + { + "label": "gui: Apply changes forgets which target it applied", + "file": "beantester/gui/app.py", + "old": " apply_settings(self.engine, s, self.log)\n" + " self._applied_target = str(s.get(\"target\", \"\")).strip()", + "new": " apply_settings(self.engine, s, self.log)", + "test": "test_a_target_that_cannot_be_used_says_everything_is_impaired", + }, + { + # "Every connection is being impaired" must not outlive the session. + "label": "gui: the target banner stays up after STOP", + "file": "beantester/gui/app.py", + "old": " self._pending_target_warning = \"\" " + "# nothing is impaired when stopped", + "new": " pass", + "test": "test_a_target_that_cannot_be_used_says_everything_is_impaired", + }, { # The exact shape before 2026-09-02: the put outside the try, and a catch # narrow enough for anything else to escape past it - which leaves @@ -719,7 +767,7 @@ # reasons, and an entry that reddens both proves neither. "label": "ratchet: the class attribute ceiling is raised above the truth", "file": "tests/test_code_shape.py", - "old": "CLASS_ATTR_CEILING = 80 # gui/app.py::App", + "old": "CLASS_ATTR_CEILING = 79 # gui/app.py::App", "new": "CLASS_ATTR_CEILING = 88 # gui/app.py::App", "test": "test_the_class_numbers_are_the_measurement_not_a_number_above_them", },