package core import ( "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 端口(取凭据用)") } t.Logf("VNC 栈已起: %s (CDP %v)", view, 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") } }