diff --git a/Agent.md b/Agent.md index c803e7e9..f4827a3a 100644 --- a/Agent.md +++ b/Agent.md @@ -118,7 +118,7 @@ Community needs voiced in HN agent-UI discussions map directly to EMRG's design: pkill -f "emrg.server"; rm -f ~/.emrg/emrgd.token; python -m emrg ``` -Python: `uv run pytest tests/ -v` (1054) — import check: `uv run python -c "from emrg.client.app import run_client"` +Python: `uv run pytest tests/ -v` (1058) — import check: `uv run python -c "from emrg.client.app import run_client"` GUI: `cd emrg/gui && npm test` (259: 45 daemon_client + 20 conn-manager + 22 app-commands + 129 renderer smoke + 15 i18n + 8 integration + 3 commands + 8 build-config + 7 gui-state + 2 tool-group) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js` CI: `uv run pytest` (ubuntu + **windows-2025 matrix** — Windows pytest 回归在 PR CI 即失败,v0.2.29 教训 #725) + GUI tests + **actionlint workflow lint** (`rhysd/actionlint@v1.7.12` gate, #444 — workflow 解析错误在 PR CI 即失败,如 `if:` secrets 上下文) Re-trigger: `scripts/re-trigger-ci.sh [branch]` (workflow_dispatch, #527 — 替代空 commit 重触发:Actions outage 会整段丢弃 push 事件,dispatch 走 API 路径不受影响) diff --git a/pyproject.toml b/pyproject.toml index 3b5908c0..cb92c503 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,3 +31,8 @@ build-backend = "hatchling.build" dev = [ "pytest>=9.1.1", ] + +[tool.pytest.ini_options] +markers = [ + "allow_real_stop: opt out of the conftest stop_all hermeticity guard for the listed stop function(s) — use only when the test has already isolated the function's internal kill machinery", +] diff --git a/tests/conftest.py b/tests/conftest.py index 35f43b13..862d1b4f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -104,26 +104,51 @@ def _ensure_git_on_path(monkeypatch): @pytest.fixture(autouse=True) -def _guard_stop_daemon_hermeticity(monkeypatch): - """⛔ Red line (host 2026-08-18T22:58): tests must NEVER trigger a real - stop_daemon() — it sends a shutdown over the websocket and kills the live - emrgd (2026-08-20 16:18/16:25 real incidents; the daemon is EMRG's life - core; rant 2026-08-20T16:32:30). Any test that calls stop_all()/ - stop_daemon() without isolating stop_daemon now fails loudly with - AssertionError instead of killing the daemon. Tests that DO isolate it - (monkeypatch.setattr(_stop_all, "stop_daemon", lambda: None)) patch after - this fixture and override it as usual. +def _guard_stop_all_hermeticity(monkeypatch, request): + """⛔ Red line (host 2026-08-18T22:58, extended by rant 2026-08-25T10:42:47): + tests must NEVER trigger a real stop step. stop_daemon() sends a shutdown + over the websocket and kills the live emrgd (2026-08-20 16:18/16:25 real + incidents; the daemon is EMRG's life core; rant 2026-08-20T16:32:30), and + stop_gui()/stop_tui()/stop_bundled_git()/stop_lock_owners() kill the live + GUI/TUI/processes on a Windows run (rant 2026-08-25T10:42:47: + test_stop_all.py:770 only isolated stop_daemon, so the other four ran for + real under the mocked Windows branch). Any test that calls stop_all()/ + stop_*() without isolating the killer now fails loudly with AssertionError + instead of killing processes. Tests that DO isolate (monkeypatch.setattr( + _stop_all, "stop_x", lambda: None)) patch after this fixture and override + it as usual. + + Escape hatch: a test that calls a stop function directly while having + already isolated its internals (e.g. stop_lock_owners with + find_install_module_holders + _lock_owner_ps mocked, or a POSIX noop) + opts out per function with @pytest.mark.allow_real_stop("stop_lock_owners"). """ import emrg._stop_all as stop_mod - def _no_real_stop_daemon(*args, **kwargs): - raise AssertionError( - "test triggered a REAL stop_daemon() — ⛔ red-line violation " - "(host 2026-08-18T22:58); tests must isolate it via " - "monkeypatch.setattr(emrg._stop_all, 'stop_daemon', lambda: None)" - ) + _STOP_FUNCS = ( + "stop_daemon", "stop_gui", "stop_tui", "stop_bundled_git", + "stop_lock_owners", + ) + + allowed = set() + marker = request.node.get_closest_marker("allow_real_stop") + if marker is not None: + allowed = set(marker.args) + + def _no_real_stop(name): + def _raises(*args, **kwargs): + raise AssertionError( + f"test triggered a REAL {name}() — ⛔ red-line violation " + f"(host 2026-08-18T22:58 / rant 2026-08-25T10:42:47); tests must " + f"isolate it via monkeypatch.setattr(emrg._stop_all, {name!r}, " + f"lambda: None)" + ) + _raises.__name__ = f"_no_real_{name}" + return _raises - monkeypatch.setattr(stop_mod, "stop_daemon", _no_real_stop_daemon) + for name in _STOP_FUNCS: + if name not in allowed: + monkeypatch.setattr(stop_mod, name, _no_real_stop(name)) @pytest.fixture(autouse=True) diff --git a/tests/test_stop_all.py b/tests/test_stop_all.py index 1c435ecf..65cd59c6 100644 --- a/tests/test_stop_all.py +++ b/tests/test_stop_all.py @@ -407,7 +407,10 @@ class TestLockOwners: EXTERNAL browser-harness daemon locking install\\ files; emrgd.pid/port empty and no -m emrg process, so the cmdline scan could never see it).""" + @pytest.mark.allow_real_stop("stop_lock_owners") def test_posix_noop(self, monkeypatch): + # Direct call is hermetic: is_win=False → early noop, and the only + # internal killer _lock_owner_ps is mocked (guard escape hatch). monkeypatch.setattr(_stop_all, "is_win", lambda: False) called: list = [] monkeypatch.setattr( @@ -575,7 +578,11 @@ def test_lock_owner_diag_parsed(self): assert _stop_all._lock_owner_diag("rm-diag\tabc\t2\t3\t0\n") is None assert _stop_all._lock_owner_diag("") is None + @pytest.mark.allow_real_stop("stop_lock_owners") def test_stop_lock_owners_logs_diag(self, monkeypatch, capsys): + # Direct call is hermetic: both internal killers are mocked + # (find_install_module_holders → [] and _lock_owner_ps) — no real + # taskkill can fire (guard escape hatch). monkeypatch.setattr(_stop_all, "is_win", lambda: True) monkeypatch.setattr(_stop_all, "find_install_module_holders", lambda: []) monkeypatch.setattr( @@ -772,10 +779,16 @@ def test_stop_all_process_residual_still_aborts(self, monkeypatch, capsys): residuals do not (rant 2026-08-18T21:24:48 #2c/#5).""" monkeypatch.setattr(_stop_all, "is_win", lambda: True) monkeypatch.setattr(_stop_all.time, "sleep", lambda *a, **k: None) - # ⛔ 最高原则(宿主 2026-08-18T22:58):必须隔离 stop_daemon——本机运行 - # pytest 时真实 stop_all() 会连 ws 发 shutdown 杀死正在运行的 emrgd - # (2026-08-20 16:18/16:25 两次实证)。同文件其它 stop_all() 测试均有此隔离。 + # ⛔ 最高原则(宿主 2026-08-18T22:58)+ rant 2026-08-25T10:42:47:必须隔离 + # 全部 5 个 stop 函数——本测试跑 Windows 分支,真实 stop_all() 会杀 GUI/ + # TUI/daemon/bundled git/lock owners。此前只隔离了 stop_daemon(2026-08-20 + # 16:18/16:25 实证),Windows 上跑 pytest 会真实杀掉正在运行的 GUI/TUI。 + # 隔离模式与 test_stop_all_retries_lock_kill 保持一致。 + monkeypatch.setattr(_stop_all, "stop_gui", lambda: None) + monkeypatch.setattr(_stop_all, "stop_tui", lambda: None) monkeypatch.setattr(_stop_all, "stop_daemon", lambda: None) + monkeypatch.setattr(_stop_all, "stop_bundled_git", lambda: None) + monkeypatch.setattr(_stop_all, "stop_lock_owners", lambda: None) monkeypatch.setattr(_stop_all, "check_install_writable", lambda: []) monkeypatch.setattr( _stop_all, "verify", @@ -1075,14 +1088,19 @@ def test_stop_all_prints_tee_path(self, monkeypatch, tmp_path, capsys): assert "exit code 0 (clean)" in out -class TestStopDaemonHermeticityGuard: - """⛔ Red line (host 2026-08-18T22:58): conftest's autouse guard must keep - stop_daemon patched so a forgetful test can never kill the live daemon - again (rant 2026-08-20T16:32:30). Canary checks function identity only — - it never calls any stop path.""" +class TestStopAllHermeticityGuard: + """⛔ Red line (host 2026-08-18T22:58, extended by rant 2026-08-25T10:42:47): + conftest's autouse guard must keep ALL FIVE stop functions patched so a + forgetful test can never kill the live daemon/GUI/TUI again (rants + 2026-08-20T16:32:30 / 2026-08-25T10:42:47). Canary checks function identity + only — it never calls any stop path.""" - def test_conftest_guard_stop_daemon_is_installed(self): + @pytest.mark.parametrize( + "name", + ["stop_daemon", "stop_gui", "stop_tui", "stop_bundled_git", "stop_lock_owners"], + ) + def test_conftest_guard_installed(self, name): import emrg._stop_all as stop_mod - assert stop_mod.stop_daemon.__name__ == "_no_real_stop_daemon", ( - "conftest stop_daemon guard missing — real stop_daemon reachable from tests" + assert getattr(stop_mod, name).__name__ == f"_no_real_{name}", ( + f"conftest {name} guard missing — real {name} reachable from tests" )