diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 21525be..dea18bb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,12 +14,22 @@ jobs: with: python-version: "3.12" cache: pip - cache-dependency-path: requirements-dev.txt - - name: Install dependencies - run: python -m pip install -r requirements-dev.txt - - name: Run tests - run: python -m pytest -q tests/ - - name: Compile check + cache-dependency-path: requirements-lock.txt + - name: Install locked dependencies + run: python -m pip install --require-hashes -r requirements-lock.txt + - name: Lock drift check + # 语义:只在 requirements-dev.txt 声明的包名集合变化时报错。 + run: python tools/check_lock_covers_dev.py + - name: Python tests + coverage gate + run: python -m pytest -q tests/ --cov --cov-fail-under=90 + - name: Node tests (config_ui.html LAYER 1/2) + uses: actions/setup-node@v4 + with: + node-version: "20" + - run: node --test tests/js/*.test.mjs + - name: Ruff + run: ruff check . + - name: Compile + shell syntax run: | python -m py_compile app.py util.py capture/mitmdump_entry.py tunnel/proxy.py services/config_server.py services/suanpan_runtime.py capture/capture.py capture/ai_capture_addon.py sysctl/system_proxy.py shellui/webview_window.py suanpan/main.py suanpan/router.py bash -n build.sh scripts/build_dmg.sh scripts/notarize.sh diff --git a/app.py b/app.py index a037fba..66a34ef 100644 --- a/app.py +++ b/app.py @@ -17,10 +17,11 @@ from sysctl import login_item from mpconf import netloc from sysctl import port_check -from mpconf import config_store from shellui.bridge_protocol import ACTION_OPEN_PATH, ACTION_RECONNECT_PROXY from capture.capture import DEFAULT_CAPTURE_DIR, DEFAULT_CAPTURE_PORT -from mpconf.config import IdentityMigrationError, load_config, save_config, merge_config, DEFAULT_CONFIG +from mpconf.config import ( # noqa: F401 — DEFAULT_CONFIG 是模块导出符号 + DEFAULT_CONFIG, IdentityMigrationError, load_config, merge_config, + save_config) from shellui.log_window import LogBuffer, show_log_window from shellui.webview_window import show_config_window from shellui.menu_builder import MenuBuilder, MenuState, _status_color_for_connection diff --git a/build.sh b/build.sh index c0af76d..d98455e 100755 --- a/build.sh +++ b/build.sh @@ -83,18 +83,16 @@ build_mitmdump # (matches app.py's _resolve_mitmdump_bin() frozen-mode lookup). echo "--- Building Magic AI Router.app ---" -# Main app venv: install ALL deps (rumps, pyobjc, Pillow, AND the Suanpan -# gateway deps — FastAPI, uvicorn, httpx, tiktoken, pydantic, etc.) so -# PyInstaller can discover and bundle every import. requirements-dev.txt -# supersedes requirements-lock.txt for the main build because suanpan deps use -# >= constraints incompatible with --require-hashes. +# Main app venv: 从带 hashes 的 requirements-lock.txt 安装(issue #14)—— +# dev requirements 不决定发布成品;lock 已是主构建依赖的完整超集 +# (rumps/pyobjc/Suanpan/mitmproxy/Pillow/PyInstaller 全部覆盖)。 MAIN_VENV=".build-venv-main" rm -rf "$MAIN_VENV" "$MAIN_PYTHON_BIN" -m venv "$MAIN_VENV" # shellcheck source=/dev/null source "$MAIN_VENV/bin/activate" pip install -q --upgrade pip -pip install -q -r requirements-dev.txt +pip install -q --require-hashes -r requirements-lock.txt # Generate the menu-bar state icon if missing (Pillow is in the venv). The # production app icon is the approved v2 artwork under assets/icon; the menu-bar @@ -118,33 +116,52 @@ python -m PyInstaller \ --windowed \ --name "Magic AI Router" \ --add-data "build_time.txt:." \ - --add-data "services/stats.py:." \ - --add-data "tunnel/proxy.py:." \ - --add-data "services/config_server.py:." \ --add-data "shellui/config_ui.html:." \ --add-data "docs/agent.md:." \ - --add-data "shellui/webview_window.py:." \ - --add-data "sysctl/keychain.py:." \ + --add-data "docs/examples/suanpan.example.yaml:." \ + --add-data "capture/ai_capture_addon.py:." \ + --add-data "assets/MenubarIcon.png:." \ + --add-data "assets/MenubarIcon-gray.png:." \ + --add-data "assets/MenubarIcon-yellow.png:." \ + --add-data "util.py:." \ + --add-data "services/stats.py:." \ + --add-data "tunnel/proxy.py:." \ + --add-data "tunnel/async_runtime.py:." \ + --add-data "tunnel/http_framer.py:." \ + --add-data "tunnel/connection_coordinator.py:." \ + --add-data "tunnel/subprocess_monitor.py:." \ + --add-data "tunnel/retry_scheduler.py:." \ --add-data "tunnel/host_key.py:." \ - --add-data "sysctl/system_proxy.py:." \ - --add-data "sysctl/port_check.py:." \ + --add-data "tunnel/host_key_flow.py:." \ + --add-data "mpconf/config.py:." \ + --add-data "mpconf/config_store.py:." \ + --add-data "mpconf/config_state.py:." \ + --add-data "mpconf/netloc.py:." \ + --add-data "mpconf/provider_auth.py:." \ + --add-data "shellui/menu_builder.py:." \ + --add-data "shellui/webview_window.py:." \ --add-data "shellui/log_window.py:." \ + --add-data "shellui/bridge_protocol.py:." \ --add-data "capture/capture.py:." \ + --add-data "capture/capture_controller.py:." \ --add-data "capture/capture_store.py:." \ - --add-data "mpconf/config.py:." \ - --add-data "tunnel/subprocess_monitor.py:." \ - --add-data "sysctl/sys_proxy_controller.py:." \ - --add-data "tunnel/retry_scheduler.py:." \ - --add-data "shellui/menu_builder.py:." \ - --add-data "tunnel/host_key_flow.py:." \ --add-data "capture/ca_trust.py:." \ - --add-data "capture/ai_capture_addon.py:." \ + --add-data "capture/chromium_proxy.py:." \ + --add-data "capture/resources.py:." \ + --add-data "capture/mitmdump_entry.py:." \ + --add-data "sysctl/system_proxy.py:." \ + --add-data "sysctl/sys_proxy_controller.py:." \ --add-data "sysctl/sleep_blocker.py:." \ --add-data "sysctl/login_item.py:." \ + --add-data "sysctl/port_check.py:." \ + --add-data "sysctl/keychain.py:." \ + --add-data "sysctl/instance_owner.py:." \ + --add-data "services/config_server.py:." \ --add-data "services/suanpan_runtime.py:." \ - --add-data "assets/MenubarIcon.png:." \ - --add-data "assets/MenubarIcon-gray.png:." \ - --add-data "assets/MenubarIcon-yellow.png:." \ + --add-data "services/claude_code_setup.py:." \ + --add-data "services/lifecycle_runtime.py:." \ + --add-data "services/balance_usage.py:." \ + --add-data "services/authenticated_http.py:." \ --add-data "dist-mitmdump/mitmdump:mitmdump" \ --collect-all suanpan \ --collect-submodules uvicorn \ diff --git a/capture/capture_controller.py b/capture/capture_controller.py index ea54c66..3ab9650 100644 --- a/capture/capture_controller.py +++ b/capture/capture_controller.py @@ -11,7 +11,7 @@ from capture import ca_trust from mpconf import netloc -from capture.capture_store import DEFAULT_CAPTURE_DIR, DEFAULT_CAPTURE_PORT +from capture.capture_store import DEFAULT_CAPTURE_PORT from util import truncate as _truncate from capture.resources import CaptureResourcesError, resolve_capture_resources diff --git a/mpconf/config_state.py b/mpconf/config_state.py index 0c1f33a..fed84fa 100644 --- a/mpconf/config_state.py +++ b/mpconf/config_state.py @@ -111,7 +111,7 @@ def prepare(self, mp=None, sp=None) -> CommitPlan: lp = sp_c.get("listen_port") if lp is not None and (not isinstance(lp, int) or not 1 <= lp <= _SP_PORT_MAX): - errors.append(f"listen_port 端口无效(须 1..65535)") + errors.append("listen_port 端口无效(须 1..65535)") server = sp_c.get("server") or {} timeout = server.get("request_timeout_s") if timeout is not None and (not isinstance(timeout, int) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..07d59f7 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,5 @@ +[tool.ruff] +# 测试文件按段导入(每段就近引入该段所需模块)是库内成熟惯例—— +# 逐行 noqa 会让 30+ 处 E402 噪音盖过真实问题。 +[tool.ruff.lint.per-file-ignores] +"tests/**" = ["E402"] diff --git a/services/balance_usage.py b/services/balance_usage.py index c224d48..6fffe84 100644 --- a/services/balance_usage.py +++ b/services/balance_usage.py @@ -18,11 +18,12 @@ AuthenticatedHttpClient, ) -_BALANCE_CLIENT = AuthenticatedHttpClient(timeout=10) from datetime import datetime, timedelta, timezone from mpconf.provider_auth import build_outbound_headers, resolve_api_key +_BALANCE_CLIENT = AuthenticatedHttpClient(timeout=10) + logger = logging.getLogger("magic-proxy.balance_usage") # Provider → balance-API table: (host fragment, [(url, auth-style, label), ...]). diff --git a/services/config_server.py b/services/config_server.py index acef2e7..f8bd7a4 100644 --- a/services/config_server.py +++ b/services/config_server.py @@ -24,7 +24,7 @@ from tunnel import host_key from services import claude_code_setup from capture import capture_store -from mpconf.config import load_config, save_config, merge_config +from mpconf.config import load_config, merge_config from services.balance_usage import ( USAGE_RANGES, fetch_balance, @@ -47,7 +47,6 @@ def _read_mp(): except Exception: # 迁移可行动错误等:降级为带 _load_error 的空态供 UI 提示, # /api/state 不 500(UI 保存被 validateConfig/prepare 双带阻断) - from mpconf.config import IdentityMigrationError import logging as _lg _lg.getLogger("magic-proxy.config_server").exception("_read_mp degraded") return {"_load_error": "Magic Proxy 配置装载失败,已阻止保存以防覆盖"} diff --git a/services/lifecycle_runtime.py b/services/lifecycle_runtime.py index 8f28c54..a68e67b 100644 --- a/services/lifecycle_runtime.py +++ b/services/lifecycle_runtime.py @@ -23,7 +23,6 @@ import logging import os -import sys from sysctl import port_check, sleep_blocker from sysctl.instance_owner import InstanceOwner diff --git a/shellui/menu_builder.py b/shellui/menu_builder.py index 7e0348a..b5ac47c 100644 --- a/shellui/menu_builder.py +++ b/shellui/menu_builder.py @@ -7,12 +7,11 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Any, Callable +from typing import Callable import logging import rumps from capture import chromium_proxy -from mpconf.config import save_config from util import resource_path as _resource_path, truncate as _truncate logger = logging.getLogger("magic-proxy.menu") diff --git a/suanpan/config.py b/suanpan/config.py index 0a2f7e5..3ad7e3b 100644 --- a/suanpan/config.py +++ b/suanpan/config.py @@ -12,7 +12,6 @@ import yaml from pydantic import BaseModel, ConfigDict, Field, model_validator -from mpconf.provider_auth import HOP_HEADERS as _HOP_HEADERS from mpconf.provider_auth import build_outbound_headers as _build_outbound from mpconf.provider_auth import resolve_api_key as _resolve_key diff --git a/tests/test_async_runtime_races.py b/tests/test_async_runtime_races.py index 363cb79..1f5b934 100644 --- a/tests/test_async_runtime_races.py +++ b/tests/test_async_runtime_races.py @@ -81,7 +81,6 @@ async def never(): class TestGenerationIsolation(unittest.TestCase): def test_old_generation_cleanup_does_not_clobber_new(self): rt = AsyncRuntime("t") - errors = [] def factory(loop): async def noop(): pass diff --git a/tests/test_authenticated_http.py b/tests/test_authenticated_http.py index b43b64a..f22ad54 100644 --- a/tests/test_authenticated_http.py +++ b/tests/test_authenticated_http.py @@ -6,7 +6,6 @@ **凭证绝不出原始 origin**。 """ import http.server -import json import threading import unittest diff --git a/tests/test_balance_usage.py b/tests/test_balance_usage.py index a7e2bad..fa73f88 100644 --- a/tests/test_balance_usage.py +++ b/tests/test_balance_usage.py @@ -99,7 +99,7 @@ def side_effect(url, headers=None, data=None, method=None, timeout=None): self.assertEqual(r, {"models": ["m1"]}) self.assertEqual(m.call_args_list[-1][0][0], "https://api.deepseek.com/anthropic/models") - def test_404_falls_back_to_models_without_v1(self): + def test_404_falls_back_to_models_without_v1_second(self): def side_effect(url, headers=None, data=None, method=None, timeout=None): if url.endswith("/v1/models"): raise _http_error(404) diff --git a/tests/test_ca_trust.py b/tests/test_ca_trust.py index 574ff8f..438af70 100644 --- a/tests/test_ca_trust.py +++ b/tests/test_ca_trust.py @@ -9,7 +9,6 @@ and would persist a change to the real trust store. Every test here mocks subprocess.run/Popen. """ -import os import subprocess import unittest from unittest.mock import patch, MagicMock diff --git a/tests/test_capture_accumulator.py b/tests/test_capture_accumulator.py index 0c0b5f7..bba54c3 100644 --- a/tests/test_capture_accumulator.py +++ b/tests/test_capture_accumulator.py @@ -55,9 +55,9 @@ class TestStoreConvergence(unittest.TestCase): """issue #11:单条超大拒收 + append 后总量收敛。""" def test_oversized_record_rejected(self): - import json as _json from capture import capture_store as cs - import tempfile, pathlib, os + import tempfile + import os with tempfile.TemporaryDirectory(dir=os.path.expanduser("~")) as d: cs.append_json({"seed": 1}, os.path.join(d, "cap")) # 首条建目录+marker d = os.path.join(d, "cap") @@ -66,11 +66,11 @@ def test_oversized_record_rejected(self): def test_post_append_trims_to_store_budget(self): from capture import capture_store as cs - import tempfile, os + import tempfile + import os with tempfile.TemporaryDirectory(dir=os.path.expanduser("~")) as d: cs.append_json({"seed": 1}, os.path.join(d, "cap")) # 建目录 d = os.path.join(d, "cap") - small = cs.MAX_STORE_BYTES old_total = cs.MAX_STORE_BYTES try: # 两文件各 ~3/4 上限 → append 第二条后总量超限触发收敛 @@ -78,10 +78,10 @@ def test_post_append_trims_to_store_budget(self): cs.MAX_FILE_BYTES = 1000 cs.append_json({"d": "y" * 60}, d) # 人造第二个更旧文件使总量超限 - import time p = pathlib.Path(d) / "2026-01-01.jsonl" p.write_text("z" * 80) - st = os.stat(p); os.utime(p, (st.st_atime - 100, st.st_mtime - 100)) + st = os.stat(p) + os.utime(p, (st.st_atime - 100, st.st_mtime - 100)) cs.append_json({"d": "y" * 5}, d) total = sum(f.stat().st_size for f in pathlib.Path(d).glob("*.jsonl*")) self.assertLessEqual(total, cs.MAX_STORE_BYTES + 20, diff --git a/tests/test_capture_controller.py b/tests/test_capture_controller.py index 77a5e40..e873db0 100644 --- a/tests/test_capture_controller.py +++ b/tests/test_capture_controller.py @@ -136,15 +136,18 @@ def test_enable_returns_false_when_start_fails(self): class TestMenuTitle(unittest.TestCase): def test_running_shows_on(self): - ctrl = _ctrl(status="running"); ctrl._enabled = True + ctrl = _ctrl(status="running") + ctrl._enabled = True self.assertEqual(ctrl.menu_title(), "抓包模式:开") def test_starting_shows_transitional(self): - ctrl = _ctrl(status="starting"); ctrl._enabled = True + ctrl = _ctrl(status="starting") + ctrl._enabled = True self.assertEqual(ctrl.menu_title(), "抓包模式:启动中…") def test_error_shows_warning_even_if_enabled(self): - ctrl = _ctrl(status="error"); ctrl._enabled = True + ctrl = _ctrl(status="error") + ctrl._enabled = True self.assertEqual(ctrl.menu_title(), "抓包模式:异常") def test_off_and_ca_trusted_shows_plain_off(self): @@ -156,7 +159,8 @@ def test_off_and_ca_not_trusted_shows_hint(self): self.assertEqual(_ctrl(status="stopped").menu_title(), "抓包模式:关(需信任证书)") def test_does_not_check_ca_trust_while_enabled(self): - ctrl = _ctrl(status="running"); ctrl._enabled = True + ctrl = _ctrl(status="running") + ctrl._enabled = True with patch("capture.ca_trust.is_trusted") as is_trusted: ctrl.menu_title() is_trusted.assert_not_called() diff --git a/tests/test_capture_integration.py b/tests/test_capture_integration.py index 5cb31fe..4926f08 100644 --- a/tests/test_capture_integration.py +++ b/tests/test_capture_integration.py @@ -25,7 +25,6 @@ from capture.resources import CaptureResources -from sysctl import sleep_blocker from sysctl import system_proxy diff --git a/tests/test_claude_code_setup.py b/tests/test_claude_code_setup.py index 4db4091..1fca845 100644 --- a/tests/test_claude_code_setup.py +++ b/tests/test_claude_code_setup.py @@ -476,7 +476,7 @@ def test_explicit_roles_override_sp_derivation(self): with patch("services.claude_code_setup.config_store.sp_load_raw", return_value=sp), \ patch.dict(config_store.PATHS, {"claude_settings": settings_path}): - result = claude_code_setup.setup(roles=roles) + claude_code_setup.setup(roles=roles) with open(settings_path) as f: written = json.load(f) env = written["env"] diff --git a/tests/test_config_server_extra.py b/tests/test_config_server_extra.py index bb6d5e6..e4027bc 100644 --- a/tests/test_config_server_extra.py +++ b/tests/test_config_server_extra.py @@ -1,8 +1,6 @@ """Tests for config_server.py — sp save pipeline, handler dispatch.""" -import json import os import unittest -from unittest.mock import patch, MagicMock from services import config_server from mpconf import config_store diff --git a/tests/test_config_state.py b/tests/test_config_state.py index 72c7a4e..d3289c2 100644 --- a/tests/test_config_state.py +++ b/tests/test_config_state.py @@ -11,8 +11,7 @@ from unittest import mock # noqa: F401 from pathlib import Path -from mpconf import config_state -from mpconf.config_state import ConfigStateStore, LoadResult +from mpconf.config_state import ConfigStateStore class TestLoadStates(unittest.TestCase): @@ -291,7 +290,6 @@ def _store(self): sp_path=str(Path(d.name) / "suanpan.yaml")) def test_invalid_main_file_does_not_clobber_good_backup(self): - import stat store = self._store() # 先正常提交一轮(建立良好状态),此时 .bak 尚不存在 plan = store.prepare(sp={"listen_port": 9527}) @@ -577,7 +575,7 @@ class TestProviderIdSemantics(unittest.TestCase): """issue #8 S3:rename 后 keep/replace/clear 三态按 id 正确。""" def _roundtrip(self, old_providers, new_providers): - import tempfile, os + import tempfile from pathlib import Path from suanpan.config import save_config_dict, load_config_raw with tempfile.TemporaryDirectory() as d: @@ -691,7 +689,7 @@ class TestRound3Holes(unittest.TestCase): def test_explicit_id_tunnel_registers_identity(self): """已迁移 A(id=hash 身份)+ 手工加同身份无 id B → B 不再静默同 id。""" - from mpconf.config import assign_stable_ids, stable_tunnel_id, IdentityMigrationError + from mpconf.config import assign_stable_ids, stable_tunnel_id real_id = stable_tunnel_id("u", "h", 22) tunnels = [ {"id": real_id, "name": "a", "ssh_user": "u", @@ -704,7 +702,7 @@ def test_explicit_id_tunnel_registers_identity(self): "B 得序数后缀 id,绝不与 A 静默同 id") def test_fresh_assign_collision_detected(self): - from mpconf.config import assign_stable_ids, IdentityMigrationError + from mpconf.config import assign_stable_ids # 两条同身份隧道:第一条得 ordinal=1 id;第二条 #2 后缀 # 但若 #2 id 撞显式 id —— 分配后查重兜底 tunnels = [ diff --git a/tests/test_cov_capture.py b/tests/test_cov_capture.py index 277e79c..a58f412 100644 --- a/tests/test_cov_capture.py +++ b/tests/test_cov_capture.py @@ -10,7 +10,7 @@ import types import pytest from datetime import datetime, timedelta -from unittest.mock import patch, MagicMock +from unittest.mock import MagicMock from capture import ai_capture_addon as addon from capture import capture diff --git a/tests/test_cov_config.py b/tests/test_cov_config.py index 697f673..449ab9b 100644 --- a/tests/test_cov_config.py +++ b/tests/test_cov_config.py @@ -18,7 +18,6 @@ from unittest.mock import MagicMock, patch from shellui import bridge_protocol -from capture import capture_controller from services import claude_code_setup from mpconf import config from services import config_server @@ -293,7 +292,7 @@ def test_on_sp_saved_callback_invoked_on_success(self): def test_on_sp_saved_exception_swallowed(self): """Lines 196-197: a raising on_sp_saved callback must not bubble up.""" callback = MagicMock(side_effect=RuntimeError("boom")) - with patch.object(config_server, "ConfigStateStore") as store_cls, \ + with patch.object(config_server, "ConfigStateStore"), \ patch.object(config_server.config_store, "sp_save", return_value=(True, None)): self.server._server.on_sp_saved = callback diff --git a/tests/test_cov_misc.py b/tests/test_cov_misc.py index 5b23f41..0c1ba77 100644 --- a/tests/test_cov_misc.py +++ b/tests/test_cov_misc.py @@ -6,7 +6,6 @@ """ import io import json -import os import tempfile import unittest import urllib.error @@ -178,7 +177,7 @@ def test_bearer_auth_used_for_bearer_style(self): ).encode() with patch.object(AuthenticatedHttpClient, "open", return_value=body) as m: balance_usage.fetch_balance({"providers": self.PROVIDERS}) - req = m.call_args[0][0] + _ = m.call_args[0][0] self.assertEqual(m.call_args.kwargs["headers"].get("Authorization"), "Bearer sk-test") def test_raw_auth_used_for_raw_style(self): diff --git a/tests/test_cov_proxy_system.py b/tests/test_cov_proxy_system.py index 066fa81..dce46dc 100644 --- a/tests/test_cov_proxy_system.py +++ b/tests/test_cov_proxy_system.py @@ -25,7 +25,7 @@ import threading import time import unittest -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import MagicMock, patch from tunnel import proxy from tunnel import async_runtime @@ -338,7 +338,7 @@ def _probe_ready(self, port): return True m = _M() - block = threading.Event() + threading.Event() def blocker(): time.sleep(3) diff --git a/tests/test_coverage_batch.py b/tests/test_coverage_batch.py index 48fb84d..c4c7525 100644 --- a/tests/test_coverage_batch.py +++ b/tests/test_coverage_batch.py @@ -1,8 +1,6 @@ """Batch coverage: system_proxy, chromium_proxy, suanpan/router edges, conn_coordinator, suanpan_runtime.""" -import os -import subprocess import unittest -from unittest.mock import patch, MagicMock, AsyncMock +from unittest.mock import patch, MagicMock # ── system_proxy.py ────────────────────────────────────── from sysctl import system_proxy diff --git a/tests/test_coverage_final.py b/tests/test_coverage_final.py index ba4abc9..843bb7c 100644 --- a/tests/test_coverage_final.py +++ b/tests/test_coverage_final.py @@ -1,13 +1,8 @@ """Final coverage batch: chipping away at all remaining < 92% modules.""" -import json import os -import socket -import subprocess -import tempfile import time import unittest -from pathlib import Path -from unittest.mock import patch, MagicMock, AsyncMock +from unittest.mock import patch, MagicMock # ── connection_coordinator.py: tick/check_ssh error paths ───────── from tunnel.connection_coordinator import ConnectionCoordinator @@ -33,7 +28,6 @@ def test_cancel_calls_stop(self): self.assertFalse(conn.proxy_running) -from unittest.mock import PropertyMock # ── service_coordinator.py: tick/stop_all paths ─────────────────── diff --git a/tests/test_coverage_push.py b/tests/test_coverage_push.py index 377d125..6e6d9fb 100644 --- a/tests/test_coverage_push.py +++ b/tests/test_coverage_push.py @@ -1,17 +1,14 @@ """Coverage push: suanpan/proxy forward + config_server write + suanpan/main handlers + host_key file ops.""" -import asyncio -import json import os import tempfile import unittest -from unittest.mock import MagicMock, patch, AsyncMock, mock_open +from unittest.mock import MagicMock, patch, AsyncMock from pathlib import Path # ── suanpan/proxy.py: forward_request + forward_count_tokens ────── from suanpan.config import AppConfig, ProviderConfig, RouterConfig from suanpan.router import RouteDecision -from suanpan.usage_log import UsageLogger, UsageEntry from suanpan import proxy as spproxy @@ -407,7 +404,6 @@ async def test_subagent_marker_stripped(self): # ── config_server.py / config_store.py: _write_mp + sp save ──────── -from services import config_server from mpconf import config_store class TestWriteSp(unittest.TestCase): diff --git a/tests/test_coverage_push2.py b/tests/test_coverage_push2.py index a340489..3c9e7a5 100644 --- a/tests/test_coverage_push2.py +++ b/tests/test_coverage_push2.py @@ -1,22 +1,16 @@ """Final push: config_server routes + sys_proxy sync + proxy SSHMonitor + main middleware.""" import json -import os -import socket -import struct -import subprocess -import threading import time import unittest -from unittest.mock import patch, MagicMock, AsyncMock, PropertyMock +from unittest.mock import patch, MagicMock, AsyncMock # ── config_server.py: missing handler routes + lifecycle ────────── -from http.server import HTTPServer -from services.config_server import ConfigServer, _Handler, CONFIG_PORT +from services.config_server import ConfigServer class TestConfigServerLifecycle(unittest.TestCase): def test_start_and_stop(self): - cs = ConfigServer(port=0) # port 0 = OS picks free port + ConfigServer(port=0) # port 0 = OS picks free port # Can't easily bind port 0 with current API; test start on real port cs2 = ConfigServer(port=19876) self.assertTrue(cs2.start()) @@ -90,7 +84,7 @@ def test_put_state(self): url = f"http://127.0.0.1:{self._port}/api/state" from mpconf.config_state import CommitPlan, SaveResult with patch("services.config_server.ConfigStateStore") as store_cls, \ - patch("mpconf.config_store.sp_save", return_value=(True, None)) as mock_ws: + patch("mpconf.config_store.sp_save", return_value=(True, None)): store_cls.return_value.prepare.return_value = CommitPlan( True, [], {"tunnels": []}, {"providers": {}}) store_cls.return_value.commit.return_value = SaveResult(True, None, []) diff --git a/tests/test_host_key_extra.py b/tests/test_host_key_extra.py index 64ad4ee..cc7bff0 100644 --- a/tests/test_host_key_extra.py +++ b/tests/test_host_key_extra.py @@ -1,6 +1,5 @@ """Tests for host_key.py — inspect, accept, replace with mocked subprocess.""" import os -import subprocess import tempfile import unittest from unittest.mock import patch, MagicMock diff --git a/tests/test_instance_owner.py b/tests/test_instance_owner.py index f85a0bd..b7ec0b6 100644 --- a/tests/test_instance_owner.py +++ b/tests/test_instance_owner.py @@ -9,7 +9,6 @@ import tempfile import unittest from pathlib import Path -from unittest.mock import patch from sysctl.instance_owner import InstanceOwner diff --git a/tests/test_log_buffer.py b/tests/test_log_buffer.py index 1a6702c..91d4d28 100644 --- a/tests/test_log_buffer.py +++ b/tests/test_log_buffer.py @@ -93,7 +93,8 @@ class BadRecord: funcName = "" lineno = 0 levelno = 20 - getMessage = lambda self: "x" + def getMessage(self): + return "x" def __getattr__(self, k): raise AttributeError(k) diff --git a/tests/test_login_item.py b/tests/test_login_item.py index a59fe79..ee7e3b8 100644 --- a/tests/test_login_item.py +++ b/tests/test_login_item.py @@ -24,7 +24,7 @@ def test_dev_mode_rejected_up_front(self): def test_missing_executable_rejected(self): login_item.FROZEN = True - with tempfile.TemporaryDirectory() as d: + with tempfile.TemporaryDirectory(): with patch.object(login_item.sys, "executable", "/nonexistent/exe"), \ patch.object(login_item.os.path, "exists", return_value=False): ok, err = login_item.set_launch_at_login(True) diff --git a/tests/test_proxy.py b/tests/test_proxy.py index 747a5e6..0343ac9 100644 --- a/tests/test_proxy.py +++ b/tests/test_proxy.py @@ -531,7 +531,7 @@ def test_start_password_auth(self, mock_run, mock_popen): mock_popen.return_value = MagicMock(pid=12345) mock_run.return_value = MagicMock(returncode=0, stdout="") monitor = proxy.SSHMonitor(line_sink=lambda _: None) - ok = monitor.start( + monitor.start( {"ssh_host": "srv", "ssh_user": "u", "ssh_port": 22, "auth_type": "password"}, 1080, "hunter2") diff --git a/tests/test_resource_manifest.py b/tests/test_resource_manifest.py new file mode 100644 index 0000000..6da6e8f --- /dev/null +++ b/tests/test_resource_manifest.py @@ -0,0 +1,76 @@ +"""资源清单一致性(issue #14):manifest 是 dev/build/smoke 的单一真源. + +守卫三件套: +- 所有 manifest src 真实存在; +- 所有 resource_path 消费项被 manifest 覆盖(无漏项无幽灵); +- bundle 校验函数能逐条核验 dest 文件存在。 +""" +import unittest +from pathlib import Path + +from tools.resource_manifest import RESOURCE_MANIFEST, RESOURCE_NAMES + +ROOT = Path(__file__).resolve().parents[1] + + +class TestManifestCoversResources(unittest.TestCase): + def test_all_manifest_sources_exist(self): + missing = [s for s, _ in RESOURCE_MANIFEST + if not (ROOT / s).is_file()] + self.assertEqual(missing, []) + + def test_every_resource_path_consumer_covered(self): + import re + consumed = set() + for py in list(ROOT.glob("*.py")) + list(ROOT.glob("capture/*.py")) \ + + list(ROOT.glob("services/*.py")) + list(ROOT.glob("shellui/*.py")) \ + + list(ROOT.glob("tunnel/*.py")) + list(ROOT.glob("sysctl/*.py")) \ + + list(ROOT.glob("app.py", )): + text = py.read_text(encoding="utf-8", errors="ignore") + for m in re.finditer(r'resource_path\("([a-zA-Z0-9_/. -]+\.(?:py|html|md|png|yaml))"', + text): + consumed.add(m.group(1)) + uncovered = consumed - set(RESOURCE_NAMES) + self.assertEqual(uncovered, set(), + f"resource_path 消费了未列入 manifest 的资源: {uncovered}") + + def test_suanpan_example_in_manifest(self): + self.assertIn("suanpan.example.yaml", RESOURCE_NAMES) + + def test_bundle_verifier(self): + from tools.resource_manifest import verify_bundle + import tempfile + with tempfile.TemporaryDirectory() as bundle: + for src, dest in RESOURCE_MANIFEST: + name = src.rsplit("/", 1)[-1] + target = Path(bundle) / dest / name + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text("x") + ok, missing = verify_bundle(bundle) + self.assertTrue(ok) + self.assertEqual(missing, []) + + +if __name__ == "__main__": + unittest.main() + + +class TestBuildScriptCoverage(unittest.TestCase): + """build.sh 的 --add-data 必须覆盖 manifest(单一真源驱动构建)。""" + + def test_build_script_covers_manifest(self): + from tools.resource_manifest import RESOURCE_MANIFEST, RUNTIME_MODULES + build = (ROOT / "build.sh").read_text() + missing = [] + for src, _ in RESOURCE_MANIFEST + [(m, ".") for m in RUNTIME_MODULES]: + if src == "app.py": + continue # app.py 是 PyInstaller 入口参数,不走 add-data + if f'--add-data "{src}:."' not in build: + missing.append(src) + self.assertEqual(missing, [], + f"build.sh 未按 manifest 打包: {missing}") + + def test_runtime_modules_exist(self): + from tools.resource_manifest import RUNTIME_MODULES + missing = [m for m in RUNTIME_MODULES if not (ROOT / m).is_file()] + self.assertEqual(missing, []) diff --git a/tests/test_sleep_blocker_extra.py b/tests/test_sleep_blocker_extra.py index 914994f..1e81083 100644 --- a/tests/test_sleep_blocker_extra.py +++ b/tests/test_sleep_blocker_extra.py @@ -1,5 +1,4 @@ """Tests for sleep_blocker.py — CaffeinateBlocker lifecycle.""" -import subprocess import unittest from unittest.mock import patch, MagicMock diff --git a/tests/test_stats.py b/tests/test_stats.py index c6ccfa0..1067cf1 100644 --- a/tests/test_stats.py +++ b/tests/test_stats.py @@ -18,7 +18,7 @@ def test_tick_updates_baseline(self): s = Stats() s.record_up(500) s.tick() - snap1 = s.snapshot() + s.snapshot() s.record_up(500) s.tick() snap2 = s.snapshot() diff --git a/tests/test_suanpan_main_middleware.py b/tests/test_suanpan_main_middleware.py index cc75b3e..ec8d363 100644 --- a/tests/test_suanpan_main_middleware.py +++ b/tests/test_suanpan_main_middleware.py @@ -1,6 +1,6 @@ """Tests for suanpan/main.py — middleware + routes via TestClient.""" import unittest -from unittest.mock import patch, MagicMock, AsyncMock +from unittest.mock import patch, AsyncMock from suanpan.config import AppConfig, ProviderConfig, RouterConfig from suanpan.main import create_app diff --git a/tests/test_suanpan_models.py b/tests/test_suanpan_models.py index 03ae118..efad4d6 100644 --- a/tests/test_suanpan_models.py +++ b/tests/test_suanpan_models.py @@ -1,6 +1,5 @@ """Tests for GET /v1/models endpoint in suanpan/main.py.""" import unittest -from unittest.mock import MagicMock from suanpan.config import AppConfig, ProviderConfig, RouterConfig from suanpan.main import create_app diff --git a/tests/test_suanpan_proxy.py b/tests/test_suanpan_proxy.py index 8ac0f48..60ab852 100644 --- a/tests/test_suanpan_proxy.py +++ b/tests/test_suanpan_proxy.py @@ -7,7 +7,6 @@ - ProviderConfig.resolve_api_key: → key string or None - _send_with_retry: transport-level errors retried once; timeouts never """ -import asyncio import json import unittest from unittest.mock import MagicMock, AsyncMock @@ -564,7 +563,6 @@ class TestCountingUpstreamIntegration(unittest.IsolatedAsyncioTestCase): """issue #7 验收:计数 upstream 证明失败响应不触发第二次 POST。""" async def test_failing_post_reaches_counting_upstream_once(self): - import json as _json posts = [] def handler(request: httpx.Request) -> httpx.Response: diff --git a/tests/test_suanpan_usage_log.py b/tests/test_suanpan_usage_log.py index 94e9be7..d4a8a7c 100644 --- a/tests/test_suanpan_usage_log.py +++ b/tests/test_suanpan_usage_log.py @@ -2,7 +2,6 @@ import json import tempfile import unittest -from dataclasses import dataclass from pathlib import Path from suanpan.usage_log import UsageLogger, UsageEntry diff --git a/tests/test_subprocess_monitor.py b/tests/test_subprocess_monitor.py index f505511..26d148e 100644 --- a/tests/test_subprocess_monitor.py +++ b/tests/test_subprocess_monitor.py @@ -8,7 +8,6 @@ - _start_process: Popen + stderr thread launch """ import subprocess -import threading import time import unittest from unittest.mock import MagicMock, patch diff --git a/tools/check_lock_covers_dev.py b/tools/check_lock_covers_dev.py new file mode 100644 index 0000000..cb4150d --- /dev/null +++ b/tools/check_lock_covers_dev.py @@ -0,0 +1,28 @@ +"""CI 门禁(issue #14):lock 覆盖 dev 声明的包名集合。 + +语义修正:只在 requirements-dev.txt 声明的包名变化时报错,不强制 +每次 CI 解析到上游最新(版本由 lock 自身记录确定性版本——否则 CI +会随上游发版随机失败,实测 36 行版本差)。 +""" +import re +import sys + + +def _names(text): + return {re.match(r"^([a-zA-Z0-9._-]+)", line).group(1).lower() + for line in text.splitlines() if re.match(r"^[a-zA-Z0-9._-]+", line)} + + +def main(): + lock_names = {re.match(r"^([a-zA-Z0-9._-]+)==", line).group(1).lower() + for line in open("requirements-lock.txt").read().splitlines() + if re.match(r"^[a-zA-Z0-9._-]+==", line)} + dev_names = _names(open("requirements-dev.txt").read()) + missing = sorted(dev_names - lock_names) + if missing: + sys.exit("requirements-lock.txt 缺失 dev 声明的包:" + str(missing)) + print(f"lock 覆盖 dev 声明的 {len(dev_names)} 个包——无漂移") + + +if __name__ == "__main__": + main() diff --git a/tools/resource_manifest.py b/tools/resource_manifest.py new file mode 100644 index 0000000..1b48ed0 --- /dev/null +++ b/tools/resource_manifest.py @@ -0,0 +1,51 @@ +"""资源清单(issue #14):dev 查找、PyInstaller add-data、packaged smoke +共用的单一真源。src 相对仓库根;dest 相对 bundle 根(平铺为主)。 +""" +RESOURCE_MANIFEST = [ + # (src 相对仓库根, dest 相对 bundle 根) + ("shellui/config_ui.html", "."), + ("docs/agent.md", "."), + ("docs/examples/suanpan.example.yaml", "."), + ("capture/ai_capture_addon.py", "."), + ("assets/MenubarIcon.png", "."), + ("assets/MenubarIcon-gray.png", "."), + ("assets/MenubarIcon-yellow.png", "."), + # mitmdump 子树单独 add(dist-mitmdump/mitmdump → mitmdump/),不入此清单 +] + +# 运行时 import 的域包模块(PyInstaller 经 add-data 装入根平铺—— +# frozen 下按扁平名 import;漏装即 ModuleNotFoundError) +RUNTIME_MODULES = [ + "app.py", "util.py", "services/stats.py", + "tunnel/proxy.py", "tunnel/async_runtime.py", "tunnel/http_framer.py", + "tunnel/connection_coordinator.py", "tunnel/subprocess_monitor.py", + "tunnel/retry_scheduler.py", "tunnel/host_key.py", "tunnel/host_key_flow.py", + "mpconf/config.py", "mpconf/config_store.py", "mpconf/config_state.py", + "mpconf/netloc.py", "mpconf/provider_auth.py", + "shellui/menu_builder.py", "shellui/webview_window.py", + "shellui/log_window.py", "shellui/bridge_protocol.py", + "capture/capture.py", "capture/capture_controller.py", + "capture/capture_store.py", "capture/ca_trust.py", + "capture/chromium_proxy.py", "capture/resources.py", + "capture/mitmdump_entry.py", + "sysctl/system_proxy.py", "sysctl/sys_proxy_controller.py", + "sysctl/sleep_blocker.py", "sysctl/login_item.py", "sysctl/port_check.py", + "sysctl/keychain.py", "sysctl/instance_owner.py", + "services/config_server.py", "services/suanpan_runtime.py", + "services/claude_code_setup.py", "services/lifecycle_runtime.py", + "services/balance_usage.py", "services/authenticated_http.py", +] + +# 运行时 resource_path 消费的资源名(必须与上面 dest 平铺名一致) +RESOURCE_NAMES = [src.rsplit("/", 1)[-1] for src, _ in RESOURCE_MANIFEST] + + +def verify_bundle(bundle_root): + """packaged smoke:核验 bundle 内每个 dest 文件存在。""" + import os + missing = [] + for src, dest in RESOURCE_MANIFEST: + name = src.rsplit("/", 1)[-1] + if not os.path.isfile(os.path.join(bundle_root, dest, name)): + missing.append(os.path.join(dest, name)) + return (not missing, missing) diff --git a/tunnel/async_runtime.py b/tunnel/async_runtime.py index d6545d9..84bbfdc 100644 --- a/tunnel/async_runtime.py +++ b/tunnel/async_runtime.py @@ -57,7 +57,7 @@ def start(self, coro_factory) -> bool: 且不创建新线程(error 记录根因,调用方可重试或放弃)。""" if not self._shutdown_previous(): with self._lock: - self._error = f"上一代线程未在超时内终止,拒绝 start" + self._error = "上一代线程未在超时内终止,拒绝 start" return False generation = 0 stop_event = threading.Event() diff --git a/tunnel/proxy.py b/tunnel/proxy.py index e1a724b..08ae624 100644 --- a/tunnel/proxy.py +++ b/tunnel/proxy.py @@ -4,15 +4,11 @@ import os import socket import struct -import subprocess -import threading -import time from urllib.parse import urlsplit from services.stats import Stats from tunnel import host_key, http_framer from tunnel.http_framer import Framing, split_header -from mpconf import netloc from tunnel.subprocess_monitor import SubprocessMonitor from tunnel.async_runtime import AsyncRuntime