test_server_mode_lifecycle.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347
  1. import json
  2. import os
  3. import pathlib
  4. import shutil
  5. import subprocess
  6. import sys
  7. import tempfile
  8. import unittest
  9. PROJECT_ROOT = pathlib.Path(__file__).resolve().parent.parent
  10. class TestServerModeFullLifecycle(unittest.TestCase):
  11. """Server 模式(除安装与卸载外)全流程本地端到端测试套件"""
  12. def setUp(self):
  13. self.tmp_dir = tempfile.TemporaryDirectory()
  14. self.work_dir = pathlib.Path(self.tmp_dir.name)
  15. def tearDown(self):
  16. self.tmp_dir.cleanup()
  17. def _setup_mock_repo(self) -> pathlib.Path:
  18. """初始化一个真实的隔离 Git 仓库并克隆项目脚本与配置"""
  19. repo_dir = self.work_dir / "target_repo"
  20. repo_dir.mkdir()
  21. subprocess.run(["git", "init", "-b", "main"], cwd=str(repo_dir), check=True, capture_output=True)
  22. subprocess.run(["git", "config", "user.name", "Tester"], cwd=str(repo_dir), check=True)
  23. subprocess.run(["git", "config", "user.email", "tester@example.com"], cwd=str(repo_dir), check=True)
  24. # 拷贝必要目录与脚本
  25. shutil.copytree(PROJECT_ROOT / "scripts", repo_dir / "scripts")
  26. shutil.copytree(PROJECT_ROOT / "configs", repo_dir / "configs")
  27. # 提交初始版本以确立 HEAD
  28. subprocess.run(["git", "add", "."], cwd=str(repo_dir), check=True)
  29. subprocess.run(["git", "commit", "-m", "initial commit"], cwd=str(repo_dir), check=True, capture_output=True)
  30. return repo_dir
  31. def _write_mock_api_payload(self, records: list) -> pathlib.Path:
  32. """写入符合 vps789.com OpenAPI 格式的本地 JSON 文件"""
  33. payload_file = self.work_dir / "vps789_api_response.json"
  34. payload = {
  35. "code": 200,
  36. "message": "success",
  37. "data": records,
  38. }
  39. payload_file.write_text(json.dumps(payload, ensure_ascii=False, indent=2))
  40. return payload_file
  41. def test_server_mode_full_lifecycle_api_fetch_and_git_commit(self):
  42. """[全流程 1] API 拉取 -> 候选过滤 -> 加权选优 -> 运行时落盘 -> Worktree 隔离分支提交"""
  43. repo_dir = self._setup_mock_repo()
  44. config_path = repo_dir / "configs" / "config.server.json"
  45. # 构造典型候选数据集
  46. records = [
  47. # 候选 1: 优质节点 (移动延迟 38ms, 丢包 0, 健康分 105) -> 加权得分 38.0,应胜出
  48. {
  49. "ip": "best.node.example.com",
  50. "createdTime": "2026-06-01 12:00:00",
  51. "avgLatency": "80",
  52. "avgPkgLostRate": "0",
  53. "ydLatency": 38.0,
  54. "ydPkgLostRate": 0.0,
  55. "ydScore": 105.0,
  56. "hostProvider": "Official",
  57. },
  58. # 候选 2: 次优节点 (移动延迟 65ms, 丢包 0, 健康分 90) -> 加权得分 65.0
  59. {
  60. "ip": "second.node.example.com",
  61. "createdTime": "2026-06-01 12:00:00",
  62. "avgLatency": "95",
  63. "avgPkgLostRate": "0",
  64. "ydLatency": 65.0,
  65. "ydPkgLostRate": 0.0,
  66. "ydScore": 90.0,
  67. "hostProvider": "Official",
  68. },
  69. # 候选 3: 黑名单提供商,必须被 record_filter.exclude_if_any 过滤
  70. {
  71. "ip": "filtered.cf.example.com",
  72. "createdTime": "2026-06-01 12:00:00",
  73. "avgLatency": "20",
  74. "avgPkgLostRate": "0",
  75. "ydLatency": 20.0,
  76. "ydPkgLostRate": 0.0,
  77. "ydScore": 110.0,
  78. "hostProvider": "CF优选IP",
  79. },
  80. # 候选 4: 纯 IP,必须被 domain_filter 过滤
  81. {
  82. "ip": "198.51.100.1",
  83. "createdTime": "2026-06-01 12:00:00",
  84. "avgLatency": "10",
  85. "avgPkgLostRate": "0",
  86. "ydLatency": 10.0,
  87. "ydPkgLostRate": 0.0,
  88. "ydScore": 110.0,
  89. "hostProvider": "Official",
  90. },
  91. ]
  92. payload_file = self._write_mock_api_payload(records)
  93. # 替换 config.server.json 中的 URL 为 file:// 路径
  94. with open(config_path, "r", encoding="utf-8") as f:
  95. cfg = json.load(f)
  96. cfg["api"]["url"] = f"file://{payload_file}"
  97. with open(config_path, "w", encoding="utf-8") as f:
  98. json.dump(cfg, f, indent=2, ensure_ascii=False)
  99. script_path = repo_dir / "scripts" / "server" / "run_update_and_commit.sh"
  100. env = os.environ.copy()
  101. env["APP_DIR"] = str(repo_dir)
  102. env["GIT_RUNTIME_BRANCH"] = "runtime-state"
  103. env["GIT_PUSH_ENABLED"] = "0"
  104. env["PYTHON_BIN"] = sys.executable
  105. # 首次执行:通过 --force-commit 触发完整工作流
  106. proc = subprocess.run(
  107. ["bash", str(script_path), "--force-commit", str(config_path)],
  108. env=env,
  109. capture_output=True,
  110. text=True,
  111. cwd=str(repo_dir),
  112. )
  113. self.assertEqual(proc.returncode, 0, f"执行失败:\nSTDOUT: {proc.stdout}\nSTDERR: {proc.stderr}")
  114. # 1. 验证落盘文件正确性 (锚定在 repo_dir/runtime,决不是 repo_dir/configs/runtime)
  115. runtime_dir = repo_dir / "runtime"
  116. self.assertTrue(runtime_dir.exists(), "必须生成 runtime 目录")
  117. self.assertFalse((repo_dir / "configs" / "runtime").exists(), "严禁在 configs/ 下生成 runtime")
  118. txt_file = runtime_dir / "current_domain.txt"
  119. json_file = runtime_dir / "current_domain.json"
  120. state_file = runtime_dir / "state.json"
  121. vars_file = runtime_dir / "substore_vars.json"
  122. self.assertTrue(txt_file.exists())
  123. self.assertTrue(json_file.exists())
  124. self.assertTrue(state_file.exists())
  125. self.assertTrue(vars_file.exists())
  126. # 2. 验证选优结果(best.node.example.com 胜出)
  127. selected_domain = txt_file.read_text().strip()
  128. self.assertEqual(selected_domain, "best.node.example.com")
  129. # 3. 验证 JSON 结构和 Sub-Store 契约
  130. json_data = json.loads(json_file.read_text())
  131. self.assertEqual(json_data["domain"], "best.node.example.com")
  132. self.assertEqual(json_data["status"], "ok")
  133. self.assertIn("top_candidates", json_data)
  134. # 验证过滤规则生效:CF优选IP 与纯 IP 不在候选列表中
  135. candidate_domains = [c["domain"] for c in json_data["top_candidates"]]
  136. self.assertNotIn("filtered.cf.example.com", candidate_domains)
  137. self.assertNotIn("198.51.100.1", candidate_domains)
  138. vars_data = json.loads(vars_file.read_text())
  139. self.assertEqual(vars_data["AUTO_DOMAIN"], "best.node.example.com")
  140. self.assertEqual(vars_data["STATUS"], "ok")
  141. # 4. 验证 Git 分支已自动建立孤儿分支 runtime-state 并写入 commit
  142. commit_log = subprocess.check_output(
  143. ["git", "log", "-n", "1", "--format=%B", "runtime-state"],
  144. cwd=str(repo_dir),
  145. text=True,
  146. ).strip()
  147. self.assertIn("manual: value best.node.example.com", commit_log)
  148. # 5. 验证孤儿分支上的文件确实位于 runtime/ 路径下
  149. tree_files = subprocess.check_output(
  150. ["git", "ls-tree", "-r", "--name-only", "runtime-state"],
  151. cwd=str(repo_dir),
  152. text=True,
  153. ).splitlines()
  154. self.assertIn("runtime/current_domain.txt", tree_files)
  155. self.assertIn("runtime/current_domain.json", tree_files)
  156. self.assertIn("runtime/substore_vars.json", tree_files)
  157. self.assertIn("runtime/state.json", tree_files)
  158. def test_server_mode_adaptive_interval_window(self):
  159. """[全流程 2] 动态自适应执行窗口:非高峰期非整点窗口跳过执行,高峰期窗口正常推进"""
  160. repo_dir = self._setup_mock_repo()
  161. config_path = repo_dir / "configs" / "config.server.json"
  162. script_path = repo_dir / "scripts" / "server" / "run_update_and_commit.sh"
  163. env = os.environ.copy()
  164. env["APP_DIR"] = str(repo_dir)
  165. env["GIT_RUNTIME_BRANCH"] = "runtime-state"
  166. env["GIT_PUSH_ENABLED"] = "0"
  167. env["PYTHON_BIN"] = sys.executable
  168. # 场景 A: 设定高峰期为 02:00 - 03:00,周期 60 分钟,容差 1 分钟
  169. # 若当前时间不是 02 点且不是整点,必定触发 off-peak run skipped
  170. env_skip = env.copy()
  171. env_skip["PEAK_START_HOUR"] = "2"
  172. env_skip["PEAK_END_HOUR"] = "3"
  173. env_skip["OFFPEAK_INTERVAL_MIN"] = "60"
  174. env_skip["PEAK_INTERVAL_MIN"] = "2"
  175. proc_skip = subprocess.run(
  176. ["bash", str(script_path), str(config_path)],
  177. env=env_skip,
  178. capture_output=True,
  179. text=True,
  180. cwd=str(repo_dir),
  181. )
  182. self.assertEqual(proc_skip.returncode, 0)
  183. # 如果恰逢整点则可能执行,否则必定跳过;测试两种合法分支
  184. combined_skip = f"{proc_skip.stdout}\n{proc_skip.stderr}"
  185. if "off-peak run skipped" in combined_skip:
  186. self.assertIn("boundary", combined_skip)
  187. # 场景 B: 设定全天 00:00 - 24:00 均为高峰期,必定不跳过自适应窗口
  188. payload_file = self._write_mock_api_payload([
  189. {"ip": "peak.node.example.com", "ydLatency": 30.0, "ydPkgLostRate": 0.0, "ydScore": 100.0}
  190. ])
  191. with open(config_path, "r", encoding="utf-8") as f:
  192. cfg = json.load(f)
  193. cfg["api"]["url"] = f"file://{payload_file}"
  194. with open(config_path, "w", encoding="utf-8") as f:
  195. json.dump(cfg, f, indent=2, ensure_ascii=False)
  196. env_peak = env.copy()
  197. env_peak["PEAK_START_HOUR"] = "0"
  198. env_peak["PEAK_END_HOUR"] = "24"
  199. proc_peak = subprocess.run(
  200. ["bash", str(script_path), str(config_path)],
  201. env=env_peak,
  202. capture_output=True,
  203. text=True,
  204. cwd=str(repo_dir),
  205. )
  206. self.assertEqual(proc_peak.returncode, 0)
  207. combined_peak = f"{proc_peak.stdout}\n{proc_peak.stderr}"
  208. self.assertNotIn("off-peak run skipped", combined_peak, "高峰期窗口内绝不应跳过")
  209. self.assertIn("peak.node.example.com", (repo_dir / "runtime" / "current_domain.txt").read_text())
  210. def test_server_mode_fallback_and_silent_skip(self):
  211. """[全流程 3] 上游故障回退与静默跳过:API 故障回退至 last_good,且未变化时静默跳过 commit"""
  212. repo_dir = self._setup_mock_repo()
  213. config_path = repo_dir / "configs" / "config.server.json"
  214. # 指向一个不存在的文件 URL 模拟 API 失败
  215. with open(config_path, "r", encoding="utf-8") as f:
  216. cfg = json.load(f)
  217. cfg["api"]["url"] = "file:///nonexistent/path/never_found.json"
  218. with open(config_path, "w", encoding="utf-8") as f:
  219. json.dump(cfg, f, indent=2, ensure_ascii=False)
  220. # 预先在 state.json 中写入 last_good_domain
  221. runtime_dir = repo_dir / "runtime"
  222. runtime_dir.mkdir(parents=True, exist_ok=True)
  223. (runtime_dir / "state.json").write_text(json.dumps({
  224. "last_good_domain": "fallback-anchor.example.com",
  225. "last_run": "2026-06-01T00:00:00Z"
  226. }))
  227. script_path = repo_dir / "scripts" / "server" / "run_update_and_commit.sh"
  228. env = os.environ.copy()
  229. env["APP_DIR"] = str(repo_dir)
  230. env["GIT_RUNTIME_BRANCH"] = "runtime-state"
  231. env["GIT_PUSH_ENABLED"] = "0"
  232. env["PYTHON_BIN"] = sys.executable
  233. # 强制设置全天为高峰,防止被自适应时间窗口拦截
  234. env["PEAK_START_HOUR"] = "0"
  235. env["PEAK_END_HOUR"] = "24"
  236. # 1. 首次运行(强制提交),建立 fallback commit
  237. proc1 = subprocess.run(
  238. ["bash", str(script_path), "--force-commit", str(config_path)],
  239. env=env,
  240. capture_output=True,
  241. text=True,
  242. cwd=str(repo_dir),
  243. )
  244. self.assertEqual(proc1.returncode, 0)
  245. self.assertIn("warning: updater using fallback value", f"{proc1.stdout}\n{proc1.stderr}")
  246. self.assertEqual((runtime_dir / "current_domain.txt").read_text().strip(), "fallback-anchor.example.com")
  247. # 2. 第二次运行(普通运行,无 --force-commit):由于仍处于 fallback 状态且值未变化,应执行静默跳过!
  248. proc2 = subprocess.run(
  249. ["bash", str(script_path), str(config_path)],
  250. env=env,
  251. capture_output=True,
  252. text=True,
  253. cwd=str(repo_dir),
  254. )
  255. combined2 = f"{proc2.stdout}\n{proc2.stderr}"
  256. self.assertEqual(proc2.returncode, 0)
  257. self.assertIn("selected value unchanged (fallback-anchor.example.com)", combined2)
  258. self.assertIn("skip git commit and push", combined2)
  259. def test_server_mode_rolling_history_prune(self):
  260. """[全流程 4] 历史修剪 (Mode A): 孤儿分支提交数超过 GIT_MAX_STATE_COMMITS 时自动 Squashed Rebase"""
  261. repo_dir = self._setup_mock_repo()
  262. config_path = repo_dir / "configs" / "config.server.json"
  263. script_path = repo_dir / "scripts" / "server" / "run_update_and_commit.sh"
  264. env = os.environ.copy()
  265. env["APP_DIR"] = str(repo_dir)
  266. env["GIT_RUNTIME_BRANCH"] = "runtime-state"
  267. env["GIT_PUSH_ENABLED"] = "0"
  268. env["GIT_MAX_STATE_COMMITS"] = "3" # 最大只保留 3 个 commit
  269. env["PYTHON_BIN"] = sys.executable
  270. # 循环 5 次变换最优节点,促使产生 5 次不同的 commit
  271. for i in range(1, 6):
  272. payload_file = self._write_mock_api_payload([
  273. {
  274. "ip": f"node-{i}.example.com",
  275. "createdTime": "2026-06-01 12:00:00",
  276. "avgLatency": "50",
  277. "avgPkgLostRate": "0",
  278. "ydLatency": 20.0 + i,
  279. "ydPkgLostRate": 0.0,
  280. "ydScore": 100.0,
  281. "hostProvider": "Official",
  282. }
  283. ])
  284. with open(config_path, "r", encoding="utf-8") as f:
  285. cfg = json.load(f)
  286. cfg["api"]["url"] = f"file://{payload_file}"
  287. with open(config_path, "w", encoding="utf-8") as f:
  288. json.dump(cfg, f, indent=2, ensure_ascii=False)
  289. proc = subprocess.run(
  290. ["bash", str(script_path), "--force-commit", str(config_path)],
  291. env=env,
  292. capture_output=True,
  293. text=True,
  294. cwd=str(repo_dir),
  295. )
  296. self.assertEqual(proc.returncode, 0)
  297. # 检查 runtime-state 的 commit 总数:必须严格等于 GIT_MAX_STATE_COMMITS (3)
  298. final_count = int(subprocess.check_output(
  299. ["git", "rev-list", "--count", "runtime-state"],
  300. cwd=str(repo_dir),
  301. text=True,
  302. ).strip())
  303. self.assertEqual(final_count, 3, "超过阈值后必须自动修剪至 3 个 commit")
  304. if __name__ == "__main__":
  305. unittest.main()