From b6e687b567f9f67a33a5345a0b9fb2bdbccbc357 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Mon, 12 Oct 2026 07:20:48 +0800 Subject: [PATCH] =?UTF-8?q?test(e2e):=20=E8=A1=A5=E9=BD=90=20Cline/paste/m?= =?UTF-8?q?anual=20=E4=B8=89=E6=9D=A1=E6=B5=81=E7=A8=8B=E7=9C=9F=E4=BA=8C?= =?UTF-8?q?=E8=BF=9B=E5=88=B6=E7=AB=AF=E5=88=B0=E7=AB=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TestChannelsFlowsE2E 用真二进制 + 假平台(loopback 白名单天然放行)跑通: ① Cline(WorkOS device-poll 两步) 假上游:device code → authenticate(首拍 authorization_pending,次拍 access/refresh) → register 换 CLINE-E2E-AT。 断言:首拍会话仍 pending;次拍 completed 且 account = register userInfo.email(dev@example.com);register 恰好调用一次(防重复换证); /v1/models 出现 stealth/space-bunny-alpha(凭据已是 register 的最终 token,不是 WorkOS token —— 这是 deviceRegister 第二跳的价值)。 ② paste(opencode) start_login 会话 flow=paste → complete_login 贴 OC-E2E-KEY → /v1/models 出现 big-pickle。验证 WebUI 粘贴路径(chComplete)能落保险库。 ③ manual(qoder) start_login 返回 manual_hint 含 'WASM'(明确说明不可自动而非假装 成功)→ complete_login 贴 QD-E2E-KEY → /v1/models 出现 qoder-default。 三条流程统一终局标准:/v1/models 出现该平台模型 = 接入源成功且可路由。 e2e 全量 10 项 PASS。 --- e2e/channels_flows_e2e_test.go | 200 +++++++++++++++++++++++++++++++++ 1 file changed, 200 insertions(+) create mode 100644 e2e/channels_flows_e2e_test.go diff --git a/e2e/channels_flows_e2e_test.go b/e2e/channels_flows_e2e_test.go new file mode 100644 index 0000000..8fd4458 --- /dev/null +++ b/e2e/channels_flows_e2e_test.go @@ -0,0 +1,200 @@ +package e2e + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" +) + +// TestChannelsFlowsE2E 用真二进制补齐另外三条流程的端到端: +// +// Cline(WorkOS device-poll 两步:authenticate → register 换最终凭据) +// paste(opencode:直接 complete_login 贴 API key) +// manual(qoder:start_login 返回 manual_hint,再凭据完成) +// +// 终局证据与 TestChannelsDevicePollE2E 同一标准:/v1/models 出现该平台模型。 +func TestChannelsFlowsE2E(t *testing.T) { + var authHits, regHits int32 + plat := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + // ---- Cline(WorkOS)---- + case "/user_management/authorize/device": + fmt.Fprint(w, `{"device_code":"CD-E2E","user_code":"CLNE-1234","verification_uri":"https://example/activate","interval":1,"expires_in":600}`) + case "/user_management/authenticate": + if atomic.AddInt32(&authHits, 1) == 1 { + w.WriteHeader(400) + fmt.Fprint(w, `{"error":"authorization_pending"}`) + return + } + fmt.Fprint(w, `{"access_token":"WO-E2E-AT","refresh_token":"WO-E2E-RT"}`) + case "/api/v1/auth/register": + atomic.AddInt32(®Hits, 1) + fmt.Fprint(w, `{"success":true,"data":{"accessToken":"CLINE-E2E-AT","refreshToken":"CLINE-E2E-RT","userInfo":{"email":"dev@example.com"}}}`) + // ---- 上游推理面(opencode/qoder/cline 的 base_url 都指到这里)---- + case "/v1/models": + fmt.Fprint(w, `{"object":"list","data":[{"id":"big-pickle","object":"model"},{"id":"qoder-default","object":"model"},{"id":"stealth/space-bunny-alpha","object":"model"}]}`) + case "/v1/chat/completions": + fmt.Fprint(w, `{"choices":[{"message":{"content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}`) + default: + w.WriteHeader(404) + } + })) + defer plat.Close() + + dir := t.TempDir() + addr := mustAddr(t) + pluginDir := filepath.Join(dir, "plugins") + cfg := "listen: " + addr + "\n" + + "gateway_keys:\n - sk-e2e-0001\n" + + "default_model: none\n" + + "adapter_dir: " + filepath.Join(dir, "adapters") + "\n" + + "plugin_dir: " + pluginDir + "\n" + + "runtime_file: " + filepath.Join(dir, "runtime.json") + "\n" + + "sources: []\n" + cfgPath := filepath.Join(dir, "config.yaml") + if err := os.WriteFile(cfgPath, []byte(cfg), 0o644); err != nil { + t.Fatal(err) + } + bin := buildBinary(t) + g := startGatewayNoModels(t, bin, addr, cfgPath) + + call := func(method, path, body string) (int, map[string]interface{}) { + resp, raw := g.do(method, path, body, true) + var m map[string]interface{} + _ = json.Unmarshal([]byte(raw), &m) + return statusOf(resp), m + } + + st, m := call("PUT", "/api/plugins/channels/kernel", + `{"actions":["add_source","status","list_sources","probe_all","reset_health"],"all_read":false}`) + if st != 200 { + t.Fatalf("授权内核动作失败: status=%d body=%v", st, m) + } + + // ---------- ① Cline:WorkOS 设备码两步 ---------- + st, m = call("POST", "/api/plugins/channels/call/set_endpoints", fmt.Sprintf( + `{"platform":"cline","base_url":%q,"device":{"code_url":%q,"token_url":%q},"register":{"url":%q}}`, + plat.URL, plat.URL+"/user_management/authorize/device", + plat.URL+"/user_management/authenticate", plat.URL+"/api/v1/auth/register")) + if st != 200 { + t.Fatalf("set_endpoints(cline) 失败: status=%d body=%v", st, m) + } + st, m = call("POST", "/api/plugins/http/channels/login", `{"platform":"cline"}`) + if st != 200 { + t.Fatalf("cline start_login status=%d body=%v", st, m) + } + sess, _ := m["session"].(map[string]interface{}) + if sess == nil || sess["user_code"] != "CLNE-1234" { + t.Fatalf("cline start_login 未透传 user_code: %v", m) + } + sid, _ := sess["id"].(string) + + // 第一拍 pending(authorization_pending),第二拍 completed + atomic.StoreInt32(&authHits, 0) + st, m = call("GET", "/api/plugins/http/channels/login/status?id="+sid, "") + if m["state"] != "pending" { + t.Fatalf("cline 轮询1 应 pending, 实得 %v", m) + } + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + st, m = call("GET", "/api/plugins/http/channels/login/status?id="+sid, "") + if m["state"] == "completed" || m["state"] == "failed" { + break + } + time.Sleep(150 * time.Millisecond) + } + if m["state"] != "completed" { + t.Fatalf("cline 轮询2 应 completed, 实得 %v", m) + } + if m["account"] != "dev@example.com" { + t.Fatalf("cline account 应取 register userInfo.email, 实得 %v", m) + } + if atomic.LoadInt32(®Hits) != 1 { + t.Fatalf("register 应恰好调用一次, 实得 %d", regHits) + } + // 凭据必须是 register 的最终 token,不是 WorkOS token + var modelsBody string + deadline = time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + resp, raw := g.do("GET", "/v1/models", "", true) + modelsBody = raw + if resp != nil && resp.StatusCode == 200 && strings.Contains(raw, "stealth/space-bunny-alpha") { + break + } + time.Sleep(150 * time.Millisecond) + } + if !strings.Contains(modelsBody, "stealth/space-bunny-alpha") { + t.Fatalf("cline 接入后 /v1/models 未出现其模型: %s", modelsBody) + } + + // ---------- ② paste(opencode):complete 直接贴 key ---------- + st, m = call("POST", "/api/plugins/channels/call/set_endpoints", fmt.Sprintf( + `{"platform":"opencode","base_url":%q}`, plat.URL)) + if st != 200 { + t.Fatalf("set_endpoints(opencode) 失败: %v", m) + } + st, m = call("POST", "/api/plugins/http/channels/login", `{"platform":"opencode"}`) + if st != 200 { + t.Fatalf("opencode start_login status=%d", st) + } + if sess, _ = m["session"].(map[string]interface{}); sess == nil || sess["flow"] != "paste" { + t.Fatalf("opencode 会话 flow 应 paste: %v", m) + } + st, m = call("POST", "/api/plugins/http/channels/login/complete", + `{"platform":"opencode","credential":"OC-E2E-KEY"}`) + if st != 200 || m["ok"] != true { + t.Fatalf("opencode complete 失败: status=%d body=%v", st, m) + } + deadline = time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + resp, raw := g.do("GET", "/v1/models", "", true) + modelsBody = raw + if resp != nil && resp.StatusCode == 200 && strings.Contains(raw, "big-pickle") { + break + } + time.Sleep(150 * time.Millisecond) + } + if !strings.Contains(modelsBody, "big-pickle") { + t.Fatalf("opencode 接入后 /v1/models 未出现 big-pickle: %s", modelsBody) + } + + // ---------- ③ manual(qoder):hint 明确 + 凭据完成 ---------- + st, m = call("POST", "/api/plugins/channels/call/set_endpoints", fmt.Sprintf( + `{"platform":"qoder","base_url":%q}`, plat.URL)) + if st != 200 { + t.Fatalf("set_endpoints(qoder) 失败: %v", m) + } + st, m = call("POST", "/api/plugins/http/channels/login", `{"platform":"qoder"}`) + if st != 200 { + t.Fatalf("qoder start_login status=%d", st) + } + hint, _ := m["manual_hint"].(string) + if !strings.Contains(hint, "WASM") { + t.Fatalf("qoder manual_hint 应说明 WASM 不可自动, 实得 %q", hint) + } + st, m = call("POST", "/api/plugins/http/channels/login/complete", + `{"platform":"qoder","credential":"QD-E2E-KEY"}`) + if st != 200 || m["ok"] != true { + t.Fatalf("qoder complete 失败: status=%d body=%v", st, m) + } + deadline = time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + resp, raw := g.do("GET", "/v1/models", "", true) + modelsBody = raw + if resp != nil && resp.StatusCode == 200 && strings.Contains(raw, "qoder-default") { + break + } + time.Sleep(150 * time.Millisecond) + } + if !strings.Contains(modelsBody, "qoder-default") { + t.Fatalf("qoder 接入后 /v1/models 未出现 qoder-default: %s", modelsBody) + } +}