- 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 落了第三份同策实现待收口;浏览器会话未挂进程关闭链
180 lines
6.1 KiB
Go
180 lines
6.1 KiB
Go
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)
|
||
}
|
||
}
|
||
}
|