Files
Cyrene_v2/internal/capability/browser/manager_test.go
T
AskaEth f1fbc249fd
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(browser): B1 接线 + B2 薄层工具面(V5)
- browsercfg 接进组合根:配置读 $DATA_DIR/config/plugins/browser.json(零 env)→ 探测(本机
  Chromium → 已在跑的 CDP)→ 不可用就不注册(原因码入日志)
- 自研最小出站 CDP 客户端(不 Enabled Runtime、隔离世界求值、不发 --enable-automation)+
  导航 + 截图 0600 落盘 + 指纹自检;工具 browser(open/screenshot/info,owner-only)
- 3 个变异自检(启动恢复 --enable-automation/无视可用性/空闲回收失效)
- 真机对照(两站各 1 次,**如实结论**):两侧都 pass ⇒ 「headless 被拦 / headful 通过」**未被证明**;
  但测到两处硬差异:无头 UA 带 HeadlessChrome、screen 800 vs 2632;两模式 webdriver 均 false
  (把 L6 的推断变成实测)。跑完 0 残留 Chrome、无对外监听端口
⚠️ 未做:MCP 自动接入(口径委派未落地)/自动注入上下文/面板页/搜索降级/docker 配方/Windows 用
  pipe(Go ExtraFiles 不支持);SSRF 落了第三份同策实现待收口;浏览器会话未挂进程关闭链
2026-10-06 20:25:16 +08:00

180 lines
6.1 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 browser
import (
"context"
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browsercfg"
)
// newTestManager 造一个管理器并在用例结束时关掉(reaper 协程必须收敛,否则用例会漏协程)。
func newTestManager(t *testing.T, avail browsercfg.Availability) *Manager {
t.Helper()
m := NewManager(Options{
Availability: avail,
Config: browsercfg.Defaults(t.TempDir()),
DataDir: t.TempDir(),
})
t.Cleanup(func() { _ = m.Close() })
return m
}
// TestManager_UnavailableDoesNotPretend 钉住「不可用就不假装可用」:没有来源时
// Available() == false,每个动作都返回 ErrUnavailable(组合根据前者决定不注册工具)。
func TestManager_UnavailableDoesNotPretend(t *testing.T) {
t.Parallel()
m := newTestManager(t, browsercfg.Availability{Usable: false, Detail: "本机没有浏览器"})
if m.Available() {
t.Fatal("没有来源时 Available() 应为 false")
}
ctx := context.Background()
if _, err := m.Open(ctx, "https://example.com/"); err == nil {
t.Error("Open 应当报不可用")
}
if _, err := m.Screenshot(ctx); err == nil {
t.Error("Screenshot 应当报不可用")
}
if _, err := m.Fingerprint(ctx); err == nil {
t.Error("Fingerprint 应当报不可用")
}
}
// TestManager_BlockedURLRejectedBeforeAnyProcess 钉住**顺序**:SSRF 守卫在起浏览器
// **之前**跑。可执行文件是假的,若顺序颠倒,这里会拿到启动错误而不是守卫错误。
func TestManager_BlockedURLRejectedBeforeAnyProcess(t *testing.T) {
t.Parallel()
m := newTestManager(t, browsercfg.Availability{
Usable: true, Source: browsercfg.SourceLocalChrome, Executable: "definitely-not-a-browser.exe",
})
_, err := m.Open(context.Background(), "http://169.254.169.254/latest/meta-data/")
if err == nil {
t.Fatal("云元数据地址应当被拒")
}
if !strings.Contains(err.Error(), "安全守卫") {
t.Fatalf("错误应来自 URL 守卫,实得: %v", err)
}
}
// TestManager_LaunchFailureIsClassified 钉住「起不来就如实报」:不存在的可执行文件
// 必须包住 ErrLaunch,而不是含糊成「失败」。
func TestManager_LaunchFailureIsClassified(t *testing.T) {
t.Parallel()
m := newTestManager(t, browsercfg.Availability{
Usable: true, Source: browsercfg.SourceLocalChrome, Executable: "definitely-not-a-browser.exe",
})
_, err := m.Open(context.Background(), "https://example.com/")
if err == nil {
t.Fatal("假可执行文件应当启动失败")
}
if !strings.Contains(err.Error(), "启动本机浏览器失败") {
t.Fatalf("错误应来自 ErrLaunch,实得: %v", err)
}
}
// TestManager_IdleRecycle 钉住空闲回收:超过空闲上限的会话在下一次判定时被关掉
// (design/28 §3「空闲 N 秒回收」)。这里直接构造会话与时间,不真起浏览器。
func TestManager_IdleRecycle(t *testing.T) {
t.Parallel()
cfg := browsercfg.Defaults(t.TempDir())
cfg.IdleTimeoutSeconds = 1
m := NewManager(Options{Availability: browsercfg.Availability{Usable: true}, Config: cfg, DataDir: t.TempDir()})
t.Cleanup(func() { _ = m.Close() })
m.mu.Lock()
m.sess = &session{} // 空会话:Close 对 nil client / nil stop 是安全的
m.lastUsed = time.Now().Add(-2 * time.Second)
m.mu.Unlock()
m.reapOnce()
m.mu.Lock()
alive := m.sess != nil
m.mu.Unlock()
if alive {
t.Fatal("空闲超时后会话应被回收")
}
}
// TestManager_KeepsFreshSession 钉住「没过期就别动它」:不活跃判定的下限。
func TestManager_KeepsFreshSession(t *testing.T) {
t.Parallel()
cfg := browsercfg.Defaults(t.TempDir())
cfg.IdleTimeoutSeconds = 3600
m := NewManager(Options{Availability: browsercfg.Availability{Usable: true}, Config: cfg, DataDir: t.TempDir()})
t.Cleanup(func() { _ = m.Close() })
m.mu.Lock()
m.sess = &session{}
m.lastUsed = time.Now()
m.mu.Unlock()
m.reapOnce()
m.mu.Lock()
alive := m.sess != nil
m.mu.Unlock()
if !alive {
t.Fatal("未超时的会话不该被回收")
}
}
// TestManager_CloseIsIdempotentAndBlocksFurtherWork 钉住关闭语义:重复 Close 不 panic,
// 关闭后动作一律报「已关闭」。
func TestManager_CloseIsIdempotentAndBlocksFurtherWork(t *testing.T) {
t.Parallel()
m := NewManager(Options{
Availability: browsercfg.Availability{Usable: true, Executable: "x.exe"},
Config: browsercfg.Defaults(t.TempDir()),
DataDir: t.TempDir(),
})
if err := m.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
if err := m.Close(); err != nil {
t.Fatalf("第二次 Close: %v", err)
}
if _, err := m.Open(context.Background(), "https://example.com/"); err == nil {
t.Fatal("关闭后 Open 应当报错")
}
}
// TestViewOf_ProjectsNormalizedConfig 钉住配置投影:profile 与截图目录落在 dataDir 下,
// 超时/空闲/上限都来自配置(不是本包另造一套默认值)。
func TestViewOf_ProjectsNormalizedConfig(t *testing.T) {
t.Parallel()
dataDir := t.TempDir()
cfg := browsercfg.Defaults(dataDir)
v := viewOf(cfg, dataDir)
if v.profileDir != browsercfg.DefaultProfileDir(dataDir) {
t.Errorf("profileDir = %q", v.profileDir)
}
if v.shotsDir != browsercfg.DefaultShotsDir(dataDir) {
t.Errorf("shotsDir = %q", v.shotsDir)
}
if v.viewportW != browsercfg.DefaultViewportWidth || v.viewportH != browsercfg.DefaultViewportHeight {
t.Errorf("视口 = %dx%d", v.viewportW, v.viewportH)
}
if v.timeout != cfg.Timeout() || v.idle != cfg.IdleTimeout() {
t.Errorf("超时投影不一致: %v / %v", v.timeout, v.idle)
}
if v.shotMaxBytes != browsercfg.DefaultScreenshotMaxBytes {
t.Errorf("截图上限 = %d", v.shotMaxBytes)
}
}
// TestReasonMessagesAreNonEmpty 钉住「原因码都有话说」:日志/面板只打原因码而不
// 附文案的地方,读的人要能懂。
func TestReasonMessagesAreNonEmpty(t *testing.T) {
t.Parallel()
for _, r := range []Reason{
ReasonLaunched, ReasonRemoteCDP, ReasonIdleRecycled, ReasonLaunchFailed,
ReasonNoPageTarget, ReasonBlockedURL, ReasonNavigateFailed, ReasonShotTooLarge, ReasonClosed,
} {
if r.Message() == "" {
t.Errorf("原因码 %q 没有文案", r)
}
}
}