feat(browser): B1 接线 + B2 薄层工具面(V5)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 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 落了第三份同策实现待收口;浏览器会话未挂进程关闭链
This commit is contained in:
AskaEth committed 2026-10-06 20:25:16 +08:00
1 parent ea150ceea9
commit f1fbc249fd
21 files changed
+3579

No files matched your search

+179
View File
@@ -0,0 +1,179 @@
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)
}
}
}