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 落了第三份同策实现待收口;浏览器会话未挂进程关闭链
This commit is contained in:
1 parent
ea150ceea9
commit
f1fbc249fd
21 files changed
+3579
No files matched your search
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user