Files
Cyrene_v2/cmd/cyrene/wire_browser_lifecycle_test.go
T
AskaEth 2d211c2d16 feat(backthink,browser): 自排工具面 C1-3 + 浏览器两处偏离收口(X2+X3 同文件一并落)
X2 C1-3(自排工具面与提示词):
- request_think(thinktool.go,照 transcode/tool.go 范式)+ reason 必填/urgency 闭集 + 双守门
  (安全闸 + owner 判定:群聊/陌生人一律拒)
- **成对**进两张唤醒态允许集(quiet + awake;CAPACITY_TOOL_SETS 是整体替换)
- 提示词抽共用段落 + 自排前缀优先(不枚举工具清单);面板加 self_wake 标签与筛选轴
- 4 个变异自检(拿掉 owner 判定/只加一张表/放宽 urgency/前端标签恒等)

X3 收口(与 X2 共用 wire_services.go ⇒ 一并落):
- 删掉第三份 SSRF 实现(guard.go),改由 io.ValidateRequestURL 唯一判定 + 薄门面(保两个错误身份)
- 浏览器会话挂进 a.close(真机:真 Chrome 被关、无残留、生产未扰动)
- a.wireBrowser() 归位到 wire_runtime.go,删掉 ioToolOptions 的临时挂点
- 5 个变异自检

顺带补提交 C1-2 漏掉的 internal/config/runtimeconfig/backthink_test.go(ed50053 漏 add)。
2026-10-06 21:16:23 +08:00

203 lines
8.0 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"
"fmt"
"net/http"
"os"
"path/filepath"
"strconv"
"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,无需外网),并从 profile 读回它的调试端点。
if _, err := tool.Execute(ctx, map[string]any{"action": "info"}); err != nil {
t.Fatalf("action=info(起真浏览器): %v", err)
}
endpoint := devToolsEndpoint(t, 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()))
}
// devToolsEndpoint 从 profile 目录的 DevToolsActivePort 读出这次会话的调试端点
// (格式与 launch.go 的 waitDevToolsPort 同源:首行是端口号)。
func devToolsEndpoint(t *testing.T, profileDir string) string {
t.Helper()
raw, err := os.ReadFile(filepath.Join(profileDir, "DevToolsActivePort"))
if err != nil {
t.Fatalf("读 DevToolsActivePort: %v(profile=%s)", err, profileDir)
}
line, _, _ := strings.Cut(strings.TrimSpace(string(raw)), "\n")
port, err := strconv.Atoi(strings.TrimSpace(line))
if err != nil || port <= 0 {
t.Fatalf("DevToolsActivePort 内容不合法: %q", raw)
}
return fmt.Sprintf("http://127.0.0.1:%d", port)
}
// 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:]
}