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) } } }