"""命令台 /console 单测:只验页面壳与路由,不跑浏览器 JS。 /console 是原生 UI 入口(见 docs/codex-native-ui-alignment.md §3): UI host 可达时 302 到 CODEX_UI_URL,不可达时回落旧控制台; /console-classic 永远 serve 旧控制台。两者都不走鉴权(只是个 壳),数据一律凭 X-API-Key 调接口,所以无 key 也应 200 出 HTML; 真正的鉴权由各 API 端点负责(已有单测覆盖)。 默认测试环境没有 UI host 在跑,所以 /console 的断言是回落行为; 302 行为由 test_console_redirects_to_ui_host_when_reachable 用 测试内起的假 UI host 单独覆盖。 """ import http.client import os import sys import tempfile import threading import unittest from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from unittest import mock try: from cloud.auth import AuthStore from cloud.server import create_server except ImportError: # discover -s cloud/tests 时把项目根目录补进 sys.path sys.path.insert(0, str(Path(__file__).resolve().parents[2])) from cloud.auth import AuthStore from cloud.server import create_server class ConsoleTestCase(unittest.TestCase): def setUp(self): self.tmp = tempfile.TemporaryDirectory() self.db_path = os.path.join(self.tmp.name, "control.db") self.auth = AuthStore(self.db_path) self.owner_id, self.owner_key = self.auth.create_user("owner") self.server = create_server(self.db_path, "127.0.0.1", 0) self.port = self.server.server_address[1] self.thread = threading.Thread( target=self.server.serve_forever, daemon=True) self.thread.start() # 回落断言必须与环境无关:显式把 UI host 指到一个不可能有 # 监听的端口(127.0.0.1:1 立即 connection refused),而不是 # 赌测试机 :8095 上恰好没服务;302 测试在内层再覆盖它。 self._env_patch = mock.patch.dict( os.environ, {"CODEX_UI_URL": "http://127.0.0.1:1/"}) self._env_patch.start() self.addCleanup(self._env_patch.stop) def tearDown(self): self.server.shutdown() self.server.server_close() self.thread.join(timeout=5) try: self.auth.conn.close() except Exception: pass self.tmp.cleanup() def _get(self, path, key=None): conn = http.client.HTTPConnection( "127.0.0.1", self.port, timeout=10) headers = {} if key is not None: headers["X-API-Key"] = key try: conn.request("GET", path, headers=headers) resp = conn.getresponse() return resp.status, dict(resp.getheaders()), resp.read() finally: conn.close() def test_console_falls_back_to_classic_when_ui_host_unreachable(self): # UI host 不可达时 /console 回落旧控制台:无 key 200 出 HTML。 status, headers, body = self._get("/console") self.assertEqual(status, 200) self.assertIn("text/html", headers.get("Content-Type", "")) self.assertIn("Codex 命令台", body.decode("utf-8")) def test_console_redirects_to_ui_host_when_reachable(self): # 假 UI host:探测只看 GET / 是否 200,内容不重要。 class _FakeUIHandler(BaseHTTPRequestHandler): def do_GET(self): body = b"ok" self.send_response(200) self.send_header("Content-Type", "text/plain") self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) def log_message(self, *args): pass fake = ThreadingHTTPServer(("127.0.0.1", 0), _FakeUIHandler) thread = threading.Thread( target=fake.serve_forever, daemon=True) thread.start() try: ui_url = "http://127.0.0.1:{}/".format( fake.server_address[1]) with mock.patch.dict(os.environ, {"CODEX_UI_URL": ui_url}): status, headers, _body = self._get("/console") self.assertEqual(status, 302) self.assertEqual(headers.get("Location"), ui_url) finally: fake.shutdown() fake.server_close() thread.join(timeout=5) def test_console_classic_serves_html_without_key(self): status, headers, body = self._get("/console-classic") self.assertEqual(status, 200) self.assertIn("text/html", headers.get("Content-Type", "")) text = body.decode("utf-8") self.assertIn("Codex 命令台", text) # 壳必须引用它实际调的接口,防 JS 与服务端路由漂移。 # React 版(2026-10-29 起):旧单页的 agentBox / threadBox / # THREAD_MUTATION_STATUSES / skillList / appList / mcpList # id 与 /thread/<动作> 字面路径已不存在,改为 React 的 # taskAgents / threadText / threadAction + 动作名 / # nav-skills;断言意图不变(壳确实调了这些接口), # 只换成构建产物里实际存在的标记。 for marker in ("/health", "/projects", "/tasks", "/workers", "X-API-Key", "taskAgents", "/agents", "peerMessage", "/messages", "threadText", "threadGateNote", "threadList", "threadAction", "/thread", "/threads", "rename", "archive", "unarchive", "compact", "nav-skills", "mcpOauthBox", "/skills", "/apps", "/mcp-servers", "/mcp-servers/reload", "/oauth-login"): self.assertIn(marker, text) def test_console_trailing_slash_same_page(self): status, _headers, body = self._get("/console/") self.assertEqual(status, 200) self.assertIn("Codex 命令台", body.decode("utf-8")) def test_console_subpath_not_routed(self): # 未知子路径先过鉴权(无 key 时 401),有 key 时才落到 404; # 绝不能把 /console/extra 也当成壳 serve 出去。 status, _headers, _body = self._get("/console/extra") self.assertEqual(status, 401) status, _headers, _body = self._get("/console/extra", self.owner_key) self.assertEqual(status, 404) def test_console_does_not_leak_data_without_key(self): # 壳里只有静态文本,没有任何任务/项目数据(数据须凭 key 调 API)。 for path in ("/console", "/console-classic"): _status, _headers, body = self._get(path) text = body.decode("utf-8") self.assertNotIn(self.owner_id, text) def test_console_init_loads_relay_models(self): # 回访用户 key 已在 localStorage 里:初始化序列必须含 # loadRelayModels 调用,否则模型下拉永远只有默认项 # (2026-10-27 实测:此前只在点“保存”时调一次)。 # React 版(2026-10-29 起):旧单页的 setKeyState/loadHealth # 序列已不存在,初始化在 useConsoleStore 的 useEffect 里, # 有 key 时调 loadRelayModels;断言意图不变,只换成 # React 构建产物里实际存在的标记。断言对象是旧控制台壳, # 所以走 /console-classic(/console 只在回落时才出这个壳)。 _status, _headers, body = self._get("/console-classic") text = body.decode("utf-8") self.assertIn("loadRelayModels", text) self.assertIn("/relay/models", text) if __name__ == "__main__": unittest.main()