feat: wire native Codex UI as /console entry, keep classic console at /console-classic
ci / test (push) Successful in 45m25s
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
This commit is contained in:
1 parent
41f3985248
commit
3e0ff898e0
20 files changed
+3018
-24
No files matched your search
@@ -0,0 +1,80 @@
|
||||
"""Codex 原生 UI host 入仓清单单测(见 docs/codex-native-ui-alignment.md §4 第 4 项)。
|
||||
|
||||
只做静态断言,不起服务、不调网络、不读密钥:
|
||||
- appserver-methods.json 是合法 JSON、179 个方法、无重复;
|
||||
- app-server-bridge.mjs 里 7 个仿真方法都在(读文件文本断言方法名出现);
|
||||
- 仓内无 cloud/codex-ui-host/original/ 目录(650MB 原版栈不入仓);
|
||||
- scripts/run-codex-ui.sh 存在且可执行。
|
||||
|
||||
路径一律以仓库根(本文件向上两级)为基准,不写死绝对路径。
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import unittest
|
||||
|
||||
REPO_ROOT = os.path.dirname(os.path.dirname(
|
||||
os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
# §4 适配 A5:150 个 UI 清单方法 + 29 个实验协议方法 = 179。
|
||||
EXPECTED_METHOD_COUNT = 179
|
||||
|
||||
# §4 适配 A1–A4:桥接层仿真的 7 个缺失方法。
|
||||
SIMULATED_METHODS = [
|
||||
"thread/stop",
|
||||
"thread/rollback",
|
||||
"thread/startAeon",
|
||||
"interactive/liveSessions/list",
|
||||
"interactive/session/upload",
|
||||
"interactive/sessionSandbox/list",
|
||||
"interactive/sessionSandbox/read",
|
||||
]
|
||||
|
||||
|
||||
class TestAppserverMethodsJson(unittest.TestCase):
|
||||
def test_valid_json_179_methods_no_duplicates(self):
|
||||
path = os.path.join(
|
||||
REPO_ROOT, "cloud", "codex-ui-host", "host",
|
||||
"appserver-methods.json")
|
||||
with open(path, encoding="utf-8") as f:
|
||||
methods = json.load(f)
|
||||
self.assertIsInstance(methods, list)
|
||||
self.assertEqual(len(methods), EXPECTED_METHOD_COUNT)
|
||||
self.assertTrue(all(isinstance(m, str) for m in methods))
|
||||
self.assertEqual(len(set(methods)), len(methods),
|
||||
"appserver-methods.json must not list a method twice")
|
||||
|
||||
|
||||
class TestAppServerBridgeSimulations(unittest.TestCase):
|
||||
def test_seven_simulated_methods_present(self):
|
||||
path = os.path.join(
|
||||
REPO_ROOT, "cloud", "codex-ui-host", "host",
|
||||
"app-server-bridge.mjs")
|
||||
with open(path, encoding="utf-8") as f:
|
||||
text = f.read()
|
||||
for name in SIMULATED_METHODS:
|
||||
self.assertIn(name, text,
|
||||
"%s must be simulated in app-server-bridge.mjs"
|
||||
% name)
|
||||
|
||||
|
||||
class TestOriginalNotVendored(unittest.TestCase):
|
||||
def test_no_original_directory_in_repo(self):
|
||||
self.assertFalse(
|
||||
os.path.exists(os.path.join(
|
||||
REPO_ROOT, "cloud", "codex-ui-host", "original")),
|
||||
"cloud/codex-ui-host/original/ must not be vendored "
|
||||
"(650MB original stack stays out of the repo)")
|
||||
|
||||
|
||||
class TestRunScript(unittest.TestCase):
|
||||
def test_run_script_exists_and_is_executable(self):
|
||||
path = os.path.join(REPO_ROOT, "scripts", "run-codex-ui.sh")
|
||||
self.assertTrue(os.path.isfile(path),
|
||||
"scripts/run-codex-ui.sh must exist")
|
||||
self.assertTrue(os.access(path, os.X_OK),
|
||||
"scripts/run-codex-ui.sh must be executable")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,8 +1,14 @@
|
||||
"""命令台 /console 单测:只验页面壳与路由,不跑浏览器 JS。
|
||||
|
||||
页面本身不走鉴权(只是个壳),数据一律凭 X-API-Key 调接口,
|
||||
所以 /console 无 key 也应 200 出 HTML;真正的鉴权由各 API
|
||||
端点负责(已有单测覆盖)。
|
||||
/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
|
||||
@@ -10,7 +16,9 @@ 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
|
||||
@@ -32,6 +40,13 @@ class ConsoleTestCase(unittest.TestCase):
|
||||
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()
|
||||
@@ -56,10 +71,47 @@ class ConsoleTestCase(unittest.TestCase):
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def test_console_serves_html_without_key(self):
|
||||
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 与服务端路由漂移。
|
||||
@@ -95,9 +147,10 @@ class ConsoleTestCase(unittest.TestCase):
|
||||
|
||||
def test_console_does_not_leak_data_without_key(self):
|
||||
# 壳里只有静态文本,没有任何任务/项目数据(数据须凭 key 调 API)。
|
||||
_status, _headers, body = self._get("/console")
|
||||
text = body.decode("utf-8")
|
||||
self.assertNotIn(self.owner_id, text)
|
||||
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 里:初始化序列必须含
|
||||
@@ -106,8 +159,9 @@ class ConsoleTestCase(unittest.TestCase):
|
||||
# React 版(2026-10-29 起):旧单页的 setKeyState/loadHealth
|
||||
# 序列已不存在,初始化在 useConsoleStore 的 useEffect 里,
|
||||
# 有 key 时调 loadRelayModels;断言意图不变,只换成
|
||||
# React 构建产物里实际存在的标记。
|
||||
_status, _headers, body = self._get("/console")
|
||||
# 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)
|
||||
|
||||
@@ -148,7 +148,10 @@ class TestMethodAccounting(unittest.TestCase):
|
||||
# 增量全是 main 侧方法(_routing_shadow、_resolve_create_
|
||||
# agent_definition、_validate_create_audit_loop),源线方法
|
||||
# 零丢失、mixin 侧零增减;此为源线 34b9a34 同款 re-pin。
|
||||
def test_total_247_no_duplicates(self):
|
||||
# 补漏注:codex 原生 UI 接线新增 2 个 server.py 自有方法
|
||||
# (_send_redirect、_serve_console_classic),总数 247 -> 249、
|
||||
# server.py 自有 153 -> 155;mixin 侧零增减,源线方法零丢失。
|
||||
def test_total_249_no_duplicates(self):
|
||||
seen = {}
|
||||
dups = []
|
||||
classes = [CloudControlPlaneHandler]
|
||||
@@ -163,9 +166,9 @@ class TestMethodAccounting(unittest.TestCase):
|
||||
dups.append((seen[name], cls.__name__, name))
|
||||
seen[name] = cls.__name__
|
||||
self.assertEqual(dups, [])
|
||||
self.assertEqual(total, 247)
|
||||
self.assertEqual(total, 249)
|
||||
self.assertEqual(len(_own_methods(CloudControlPlaneHandler)),
|
||||
247 - 94)
|
||||
249 - 94)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in new issue
Block a user