package main // 本文件钉住 `browser` 工具的**生命周期两端**(切片 X3 的收口): // // - **装配端**:接线挂点在 `wireRuntime`(与 wireVision / wireTranscode 并列), // **不再**借 `ioToolOptions()` 转交 —— 挂点是源码级事实,只有读源码才钉得住, // 因此这里照 wire_channels_test.go 的既有做法断言那一行。 // - **关闭端**:`browser` 工具持有**进程外**资源(我们起的真 Chromium 子进程 + CDP // 连接 + profile 锁),进程退出不会带走它(Windows 上父进程死不会杀子进程)。 // 常驻用例证明「关闭链真的走到了那一把工具背后的管理器」;`CYRENE_BROWSER_E2E=1` // 的真机用例进一步证明「真 Chrome 的调试端点真的不再应答」。 import ( "bytes" "context" "encoding/json" "errors" "net/http" "os" "path/filepath" "strings" "testing" "time" browsercap "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browser" "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browsercfg" "git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk" ) // TestWireRuntime_CallsWireBrowser 钉住**挂点归位**(切片 X3):browser 的接线由 // `wireRuntime` 调用,位置与 wireVision / wireTranscode 并列。 // // 为什么必须钉:这条调用是「浏览器工具在不在注册面里」的唯一开关,而它删掉之后 // **没有任何运行期症状** —— 编译照过、启动照起、日志照打,只有模型那边少了一个工具 // (本机有 Chrome 也不会注册)。这与 wire_channels_test.go 钉 VoiceTranscoder 注入 // 是同一类判据:能力静默消失只能靠测试拦。 func TestWireRuntime_CallsWireBrowser(t *testing.T) { t.Parallel() src := readSource(t, "wire_runtime.go") if !strings.Contains(src, "\ta.wireBrowser()") { t.Fatal("wire_runtime.go 里找不到 `a.wireBrowser()` 这一行 —— browser 工具不会被装配," + "模型面里静默少一个工具(挂点应与之并列 wireVision / wireTranscode)") } } // TestAppClose_ClosesBrowserSession 钉住**关闭链真的关掉了浏览器管理器**。 // // 判据不是「Close 被调过」(那只能证明调用点存在),而是**关闭之后的第一个动作**: // 管理器已进入 closed 态 ⇒ 如实报 ErrNoSession,而不是偷偷再拉起一个浏览器 // (拉起会得到 ErrLaunch,两者一眼可分)。 func TestAppClose_ClosesBrowserSession(t *testing.T) { t.Parallel() cfg := wsLoadConfig(t) // 可执行文件只需**存在**:本用例不启动它,只验证关闭链把管理器关停了。 exe := filepath.Join(t.TempDir(), "fake-browser.exe") if err := os.WriteFile(exe, []byte("x"), 0o700); err != nil { t.Fatalf("造假可执行文件: %v", err) } raw, _ := json.Marshal(map[string]any{"executable": exe}) writeBrowserConfig(t, cfg, string(raw)) var out bytes.Buffer a := browserTestApp(t, cfg, &out) a.wireBrowser() cap, ok := browserEntry(a) if !ok { t.Fatalf("可用时 browser 应进注册面(日志:%s)", out.String()) } tool, ok := cap.Value.(sdk.Tool) if !ok { t.Fatalf("注册的值不是 sdk.Tool: %T", cap.Value) } a.close(context.Background()) if !strings.Contains(out.String(), "browser tool closed") { t.Errorf("关闭链没有留下收尾记录(日志:%s)", out.String()) } if _, err := tool.Execute(context.Background(), map[string]any{"action": "info"}); !errors.Is(err, browsercap.ErrNoSession) { t.Fatalf("关闭链没有关掉浏览器管理器:err = %v,want ErrNoSession(ErrLaunch 说明它又拉起了一个)", err) } } // TestAppClose_RealChromeStopsOnShutdown 是真机端到端(默认 Skip):走**组合根的真实 // 关闭链**关掉一个**真的在跑**的 Chrome,并验证它的调试端点不再应答。 // // 用法(Windows / Git Bash): // // CYRENE_BROWSER_E2E=1 go test ./cmd/cyrene/ -run TestAppClose_RealChromeStopsOnShutdown -v -timeout 5m // // 为什么默认 Skip:它会真的起一个浏览器(headless,不弹窗),属于「人工按需复跑」, // 不该混进普通 `go test`(AGENTS §10 的压测纪律:验收里不无界占机)。 func TestAppClose_RealChromeStopsOnShutdown(t *testing.T) { if !browserE2EEnabled() { t.Skip("未设 CYRENE_BROWSER_E2E=1:真机端到端默认不跑(用法见本文件顶部)") } exe, ok := browsercfg.NewProber().FindLocalBrowser() if !ok { t.Skip("本机没有 Chrome/Chromium,真机 e2e 无法进行") } cfg := wsLoadConfig(t) raw, _ := json.Marshal(map[string]any{ "executable": exe, "headless": true, "timeoutSeconds": 60, "idleTimeoutSeconds": 600, }) writeBrowserConfig(t, cfg, string(raw)) var out bytes.Buffer a := browserTestApp(t, cfg, &out) a.wireBrowser() cap, ok := browserEntry(a) if !ok { t.Fatalf("可用时 browser 应进注册面(日志:%s)", out.String()) } tool, ok := cap.Value.(sdk.Tool) if !ok { t.Fatalf("注册的值不是 sdk.Tool: %T", cap.Value) } ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) defer cancel() // ① 起一个真浏览器(指纹自检走 CDP,无需外网),并取回它的调试端点。 if _, err := tool.Execute(ctx, map[string]any{"action": "info"}); err != nil { t.Fatalf("action=info(起真浏览器): %v", err) } endpoint, ok := browsercap.DebugEndpoint(browsercfg.DefaultProfileDir(cfg.DataDir)) if !ok { t.Fatalf("会话已建立,但 profile %s 下没有端口记录(显式端口由我们自己记,见 portrecord.go)", browsercfg.DefaultProfileDir(cfg.DataDir)) } if !endpointAlive(endpoint) { t.Fatalf("会话已建立但调试端点 %s 不应答:本用例的前提不成立", endpoint) } t.Logf("真机前置:浏览器在跑,endpoint=%s", endpoint) // ② 走生产路径关闭:组合根的 a.close(不是直接 tool.Close)。 shutCtx, shutCancel := context.WithTimeout(context.Background(), 30*time.Second) defer shutCancel() a.close(shutCtx) // ③ 进程外的那个浏览器必须真的没了:调试端点不再应答(轮询给一点收尾时间)。 if !waitEndpointGone(endpoint, 15*time.Second) { t.Fatalf("a.close 之后 %s 仍在应答:浏览器没被关掉(孤儿 Chrome 会占着 profile 锁)", endpoint) } // ④ 管理器进入 closed 态:再调动作如实报「已关闭」,不是偷偷重起一个。 if _, err := tool.Execute(context.Background(), map[string]any{"action": "info"}); !errors.Is(err, browsercap.ErrNoSession) { t.Errorf("关闭后 err = %v, want ErrNoSession", err) } t.Logf("真机关闭链:endpoint=%s 已停,管理器态=closed(日志尾部:%s)", endpoint, tailOfLog(out.String())) } // endpointAlive 报告调试端点是否在应答(一次 /json/version)。 func endpointAlive(endpoint string) bool { client := &http.Client{Timeout: 2 * time.Second} resp, err := client.Get(endpoint + "/json/version") if err != nil { return false } defer func() { _ = resp.Body.Close() }() return resp.StatusCode == http.StatusOK } // waitEndpointGone 等端点停止应答,最多等 budget。 func waitEndpointGone(endpoint string, budget time.Duration) bool { deadline := time.Now().Add(budget) for { if !endpointAlive(endpoint) { return true } if time.Now().After(deadline) { return false } time.Sleep(200 * time.Millisecond) } } // tailOfLog 取日志末尾若干字符,供真机用例把现场证据打进 t.Log。 func tailOfLog(s string) string { const keep = 400 if len(s) <= keep { return s } return s[len(s)-keep:] }