ci / test (push) Successful in 45m25s
- Vendor codex-ui-stack host/ as cloud/codex-ui-host/ (original/ not vendored, 650MB) - Bridge emulates 7 methods missing in codex 0.164.0-alpha.9, exposes 29 experimental-protocol methods (150 -> 179), Linux binary candidates, read-only POST /__codex_git - GET /console 302s to the UI host (:8095) when reachable, else falls back to the classic React console; GET /console-classic always serves it - Alignment tables: docs/codex-native-ui-alignment.md - Tests: test_console 7, new test_codex_ui_host 4, handler method pin 247 -> 249
172 lines
7.6 KiB
Python
172 lines
7.6 KiB
Python
"""命令台 /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()
|