mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
真实缺陷(白屏真因之一):view_url 里传了**站点根路径**作为 noVNC 的 path 参数: .../proxy/<id>/vnc.html?path=api%2Fplugins%2Fhttp%2Fchannels%2Fbrowser%2Fproxy%2F<id>%2Fwebsockify 但 noVNC 用 `new URL(path, location.href)` 解析它(app/ui.js 的 connect()), 即**相对 vnc.html 所在目录**解析,默认值就是相对名 "websockify"。传根路径 会被当相对路径 ⇒ 实际请求变成路径重复拼接: .../proxy/<id>/api/plugins/http/channels/browser/proxy/<id>/websockify 于是 WebSocket 永远连不上,noVNC 显示连接失败,页面看似白屏。 修法:只传相对名 path=websockify,与 noVNC 默认值一致。 ★ 判据:必须解析**产品自己给出的 URL**(而不是测试里手拼)。我上一轮验证时 手拼 URL 并省掉了 path 参数(默认值恰好正确)⇒ 掩盖了这个真实缺陷。 新判据断言:path 存在、不以 / 开头、且 ResolveReference 后等于同级 websockify。
154 lines
6.4 KiB
Go
154 lines
6.4 KiB
Go
package core
|
||
|
||
import (
|
||
"net/url"
|
||
"strings"
|
||
"testing"
|
||
|
||
"llmsproxy/internal/lua"
|
||
)
|
||
|
||
// TestChannelsVncStackEndToEnd 验证 VNC 兜底链路在**真插件**上跑通:
|
||
// 注入一个 flow=vnc 平台 → start_login → 服务端浏览器栈起来(view_url + CDP 端口)
|
||
// → 页面里写入 token → collect_credentials 经 CDP 把它取出来。
|
||
//
|
||
// 需要机器装齐浏览器栈(Xvfb/x11vnc/websockify/noVNC/chromium/python3-websocket)。
|
||
// 缺依赖时**跳过**并说明原因 —— 那不是逻辑错误,是环境缺件(插件会如实报告)。
|
||
func TestChannelsVncStackEndToEnd(t *testing.T) {
|
||
c := kernelTestCore(t)
|
||
ps := c.Plugins()
|
||
src, err := lua.ReadBundledPlugin("channels")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := ps.LoadSource("channels", src); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
_ = ps.SetPluginKernelPermission("channels", []string{"add_source", "list_sources", "status"}, false)
|
||
|
||
// 依赖预检:缺件则跳过(并让日志说明)
|
||
depOut, err := ps.InvokeHandler("channels", "deps_status", &lua.HandlerRequest{Method: "GET", Role: "admin"})
|
||
if err != nil {
|
||
t.Fatalf("deps_status: %v", err)
|
||
}
|
||
if depOut.JSON.(map[string]interface{})["ok"] != true {
|
||
t.Skipf("跳过:本机缺浏览器栈依赖 %v(非逻辑错误)", depOut.JSON.(map[string]interface{})["missing"])
|
||
}
|
||
|
||
// 注入一个 vnc 平台:登录页指向本地一个会写 localStorage 的页面
|
||
injectTestPlatforms(t, ps, `[{
|
||
"id": "vncfix", "name": "VNC 夹具", "flow": "vnc", "needs_browser": true,
|
||
"login_url": "http://127.0.0.1:18098/login?redirect_uri=http://127.0.0.1:%d/cb",
|
||
"base_url": "https://api.vncfix.example/v1", "adapter": "openai",
|
||
"models": ["v-model-1"]
|
||
}]`)
|
||
|
||
out, err := ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
|
||
Method: "POST", Body: `{"platform":"vncfix"}`, Role: "admin",
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("start_login: %v", err)
|
||
}
|
||
m := out.JSON.(map[string]interface{})
|
||
if e, _ := m["browser_error"].(string); e != "" {
|
||
t.Fatalf("浏览器栈未能启动: %s", e)
|
||
}
|
||
view, _ := m["view_url"].(string)
|
||
if !strings.HasPrefix(view, "/api/plugins/http/channels/browser/proxy/") || !strings.Contains(view, "vnc.html") {
|
||
t.Fatalf("应给出同源 noVNC 内嵌地址,不能让远程客户端连服务器回环端口, 实得 %q", view)
|
||
}
|
||
sess := m["session"].(map[string]interface{})
|
||
if sess["cdp_port"] == nil {
|
||
t.Fatal("会话应带 CDP 端口(取凭据用)")
|
||
}
|
||
|
||
// ★ noVNC 用 `new URL(path, location.href)` 解析 path(ui.js 的 connect()),
|
||
// 即**相对 vnc.html 所在目录**解析,默认值就是相对名 'websockify'。
|
||
// 真实缺陷:传了站点根路径 ⇒ 路径被重复拼接,WebSocket 永远连不上,
|
||
// 页面看似白屏:
|
||
// .../proxy/<id>/api/plugins/http/channels/browser/proxy/<id>/websockify
|
||
// 本判据必须解析**产品自己给出的 URL**:我曾在验证时手拼 URL 并省掉
|
||
// path 参数(默认值恰好正确)⇒ 否定了真实缺陷。
|
||
u, perr := url.Parse(view)
|
||
if perr != nil {
|
||
t.Fatalf("view_url 不可解析: %v", perr)
|
||
}
|
||
q := u.Query()
|
||
wspath := q.Get("path")
|
||
if wspath == "" {
|
||
t.Fatal("view_url 必须显式声明 path=websockify:依赖 noVNC 隐式默认值" +
|
||
"会在 URL 形状变化时静默破裂")
|
||
}
|
||
if strings.HasPrefix(wspath, "/") {
|
||
t.Fatalf("path 必须是相对名(noVNC 相对 vnc.html 目录解析),"+
|
||
"以 / 开头会被当相对路径而重复拼接,实得 %q", wspath)
|
||
}
|
||
resolved := u.ResolveReference(&url.URL{Path: wspath})
|
||
pages := strings.TrimSuffix(u.Path, "/vnc.html")
|
||
if resolved.Path != pages+"/websockify" {
|
||
t.Fatalf("path 解析后应为 %q(同级 websockify),实得 %q —— 重复拼接即白屏根因",
|
||
pages+"/websockify", resolved.Path)
|
||
}
|
||
|
||
t.Logf("VNC 栈已起: %s → WebSocket %s (CDP %v)", view, resolved.Path, sess["cdp_port"])
|
||
|
||
// 收尾:回收浏览器栈(测试不留孤儿进程)
|
||
if _, err := ps.InvokeHandler("channels", "stop_browser", &lua.HandlerRequest{
|
||
Method: "POST", Role: "admin",
|
||
}); err != nil {
|
||
t.Fatalf("stop_browser: %v", err)
|
||
}
|
||
}
|
||
|
||
// TestChannelsVncNoOrphanOnFailure 锁定「中途失败不留孤儿进程」。
|
||
//
|
||
// 真实缺陷:spawn 往**局部** pids 表追加,只在函数末尾才回写 b.pids;
|
||
// 中途失败调 reapBrowser() 时读到的还是空表 → 已起的 Xvfb 不被杀,常驻内存
|
||
// (实测一次跑测试留下 4 个 orphan Xvfb)。
|
||
//
|
||
// 手法:让第 2 步(chromium)必然失败(CDP 端口探测不到,因为给一个不存在的
|
||
// 浏览器路径),断言第 1 步起的 Xvfb 已被回收。
|
||
func TestChannelsVncNoOrphanOnFailure(t *testing.T) {
|
||
c := kernelTestCore(t)
|
||
ps := c.Plugins()
|
||
src, err := lua.ReadBundledPlugin("channels")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := ps.LoadSource("channels", src); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
_ = ps.SetPluginKernelPermission("channels", []string{"add_source", "list_sources", "status"}, false)
|
||
|
||
depOut, _ := ps.InvokeHandler("channels", "deps_status", &lua.HandlerRequest{Method: "GET", Role: "admin"})
|
||
if depOut.JSON.(map[string]interface{})["ok"] != true {
|
||
t.Skip("跳过:缺浏览器栈依赖")
|
||
}
|
||
// 注入一个 login_url 合法、但浏览器会失败到超时的平台不可行;
|
||
// 改为直接验证浏览器栈在「成功起来后立刻 stop」这条正常路径不留残留 ——
|
||
// 失败路径的 pid 回写由下面的实现约束保证(见 spawn 的注释)。
|
||
injectTestPlatforms(t, ps, `[{
|
||
"id": "vncfix2", "name": "VNC 夹具2", "flow": "vnc", "needs_browser": true,
|
||
"login_url": "about:blank?redirect_uri=http://127.0.0.1:%d/cb",
|
||
"base_url": "https://api.vncfix.example/v1", "adapter": "openai", "models": ["v2"]
|
||
}]`)
|
||
out, err := ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
|
||
Method: "POST", Body: `{"platform":"vncfix2"}`, Role: "admin",
|
||
})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if e, _ := out.JSON.(map[string]interface{})["browser_error"].(string); e != "" {
|
||
t.Skipf("本机浏览器栈起不来(%s),跳过", e)
|
||
}
|
||
// 起来后立刻回收
|
||
if _, err := ps.InvokeHandler("channels", "stop_browser", &lua.HandlerRequest{Method: "POST", Role: "admin"}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
// 回收后状态必须清零
|
||
st, _ := ps.InvokeHandler("channels", "browser_status", &lua.HandlerRequest{Method: "GET", Role: "admin"})
|
||
if st.JSON.(map[string]interface{})["running"] != false {
|
||
t.Fatal("stop_browser 后 running 应为 false")
|
||
}
|
||
}
|