Files
Cyrene_v2/cmd/cyrene/wire_browser_lifecycle_test.go
T
AskaEth 6fe68ecfac
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(browser): 方案 A 落地 —— 显式端口 + 去掉 stealth flag(主人 2026-10-07 拍板)
依据 `DevDocs/reference/chromium-cdp/`(9 份实测+官方源码):旧姿势传 `--remote-debugging-port=0`
会被 Chromium 当成 ChromeDriver 式自动化启动 ⇒ 主动打开 `AutomationControlled`
⇒ `navigator.webdriver=true`;那个 bad-flag 是承重覆盖,但代价是**每次有头弹横幅**。
改用显式端口后 `webdriver` **本来就是 false** ⇒ flag 变纯冗余 ⇒ 去掉它,**两者兼得**。

- `launch.go`:`--remote-debugging-port=<我们选的端口>` 替掉 `=0`;**删掉**
  `--disable-blink-features=AutomationControlled`(注释写明理由)。
- 新增 `portrecord.go`:端口来源与落点的**唯一出处** —— 取空闲端口(`net.Listen`
  取号后立刻关闭,**本进程不常驻监听**)、写/读/清**我们自己的记录**
  `<profile>/cyrene-debug-port`(选它与 Chrome 的 `DevToolsActivePort` 同目录:
  天然按 profile 隔离、profile 换掉记录自动消失、内容只有端口号不含凭据);
  **兼容老记录**(换二进制时上一版起的浏览器可能还在跑 ⇒ 认不出会把"自己的浏览器"
  误报成"profile 被别人占了");就绪判据换成「我们选的那个端口答得上 `/json/version`」;
  TOCTOU 处理 = 端口被占**换号重试一次**,重试仍失败则如实报错且不留记录。
- 三条认领路径(复用/清陈记录/如实报错保留现场)与三条失败分类(进程立刻退出 /
  等端口超时 / 预算耗尽)**全部保留**;`ErrLaunch` + `errors.Is` 口径未变;
  **绝不杀进程**红线未变。

真机自证(子代理跑,默认 Skip 的 `realchrome_test.go`,`CYRENE_BROWSER_CDP_E2E=1` 开闸):
`webdriver=false` · 命令行**无** flag/`--enable-automation`/`--headless` · UA 无
`HeadlessChrome`、`plugins=5`、`languages=zh-CN,zh`、`window.chrome` 在 ·
**UI Automation 控件树 17 个命名节点里没有「信息栏容器/信息栏」⇒ 无横幅** ·
第二次 Dial **复用同端口、浏览器主进程仍是同一个 PID** · 收尾按 profile 路径筛残留 = 0。

验收(主会话复跑 + 亲自变异):整树 `go build ./...` + 两包 vet 绿;browser 包测试绿;
我用 -overlay 把该 flag 加回参数表 ⇒ `TestLaunchArgs_HeadfulCarriesNoAutomationTraces`
变红(逐字报"不许传的 flag")。

⚠️ 未验(如实):无头档的横幅(无窗口判不了)、`chrome-headless-shell`、风控侧是否把
"端口写法"本身当信号、生产 profile 整链路(未碰)。
2026-10-07 11:18:25 +08:00

189 lines
7.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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:]
}