依据 `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 整链路(未碰)。
189 lines
7.5 KiB
Go
189 lines
7.5 KiB
Go
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:]
|
||
}
|