feat(browser): 浏览器配置与探测库(internal/capability/browsercfg,纯库零接线)

按 design/28 §2/§3 的「配置不进 .env」口径:读 $DATA_DIR/config/plugins/browser.json
(0600 + temp+rename 范式,**不读 env、不加新键**);有头/无头、CDP 端点、profile 目录、
视口/超时/并发(1)/截图上限全部类型化 + 常量默认值;**探测优先级**(本机 Chrome/Chromium
可执行文件 → 已在跑的容器)并返回可分类的「为什么不可用」(供上层如实降级,不假装可用);
profile 目录含凭据 ⇒ 0600 + 不进 git/不进上下文的注释与校验。

本片零既有文件改动(不注册工具、不碰 cmd/),接线留 B2。
This commit is contained in:
AskaEth committed 2026-10-06 09:03:34 +08:00
1 parent b7fb4dd1c7
commit 29181245ce
9 files changed
+1398

No files matched your search

@@ -0,0 +1,194 @@
package browsercfg
import (
"errors"
"path/filepath"
"testing"
"time"
)
// TestConfigNormalizeFillsDefaults 钉住「零值 + Normalize = 全默认」(design/28 §8 B1:
// 代码只提供默认值)。Config 全字段可比较,故整struct 比对。
func TestConfigNormalizeFillsDefaults(t *testing.T) {
got := Config{}.Normalize("/data")
want := Config{
Headless: DefaultHeadless,
ProfileDir: filepath.Join("/data", "browser", "profile"),
ViewportWidth: DefaultViewportWidth,
ViewportHeight: DefaultViewportHeight,
TimeoutSeconds: DefaultTimeoutSeconds,
IdleTimeoutSeconds: DefaultIdleTimeoutSeconds,
Concurrency: DefaultConcurrency,
ScreenshotMaxBytes: DefaultScreenshotMaxBytes,
}
if got != want {
t.Fatalf("Normalize 默认值回落不符\n got=%+v\nwant=%+v", got, want)
}
if got.Headless != false {
t.Fatalf("默认档必须是有头(design/28 §2),got headless=%v", got.Headless)
}
}
// TestConfigNormalizeKeepsExplicit 钉住「显式值不被默认覆盖」。
func TestConfigNormalizeKeepsExplicit(t *testing.T) {
in := Config{
Headless: true,
Executable: "/opt/chrome",
CDPEndpoint: "http://127.0.0.1:9333",
ProfileDir: "/tmp/p",
ViewportWidth: 1920,
ViewportHeight: 1080,
TimeoutSeconds: 5,
IdleTimeoutSeconds: 60,
Concurrency: 1,
ScreenshotMaxBytes: 1 << 20,
}
if got := in.Normalize("/data"); got != in {
t.Fatalf("Normalize 改动了显式值\n got=%+v\nwant=%+v", got, in)
}
}
// TestConfigNormalizeNonPositiveUsesDefault 钉住「≤0 = 用默认」(含负数)。
func TestConfigNormalizeNonPositiveUsesDefault(t *testing.T) {
in := Config{
ViewportWidth: -1,
ViewportHeight: 0,
TimeoutSeconds: -30,
IdleTimeoutSeconds: 0,
Concurrency: -2,
ScreenshotMaxBytes: -5,
}
got := in.Normalize("/data")
if got.ViewportWidth != DefaultViewportWidth || got.ViewportHeight != DefaultViewportHeight {
t.Fatalf("视口未回落默认:%d×%d", got.ViewportWidth, got.ViewportHeight)
}
if got.TimeoutSeconds != DefaultTimeoutSeconds || got.IdleTimeoutSeconds != DefaultIdleTimeoutSeconds {
t.Fatalf("超时未回落默认:timeout=%d idle=%d", got.TimeoutSeconds, got.IdleTimeoutSeconds)
}
if got.Concurrency != DefaultConcurrency {
t.Fatalf("并发未回落默认:%d", got.Concurrency)
}
if got.ScreenshotMaxBytes != DefaultScreenshotMaxBytes {
t.Fatalf("截图字节上限未回落默认:%d", got.ScreenshotMaxBytes)
}
}
// TestConfigNormalizeClampsConcurrency 钉住并发上限恒为 1(design/28 §3、§6 坑 2:profile 锁)。
func TestConfigNormalizeClampsConcurrency(t *testing.T) {
for _, in := range []int{2, 8, 100} {
if got := (Config{Concurrency: in}).Normalize("/data").Concurrency; got != DefaultConcurrency {
t.Fatalf("Concurrency=%d 未被夹到 %d,got=%d", in, DefaultConcurrency, got)
}
}
}
// TestDefaultsMatchesZeroNormalize 钉住 Defaults 与「零值 Normalize」是同一件事。
func TestDefaultsMatchesZeroNormalize(t *testing.T) {
if got, want := Defaults("/d"), (Config{}).Normalize("/d"); got != want {
t.Fatalf("Defaults 与 Normalize 口径不一致\n got=%+v\nwant=%+v", got, want)
}
}
// TestConfigValidateAcceptsDefaults 钉住「全默认配置合法」——Validate 判的是原始值,
// 零值代表「用默认」,不该被判非法。
func TestConfigValidateAcceptsDefaults(t *testing.T) {
if err := (Config{}).Validate(); err != nil {
t.Fatalf("零值配置应合法(= 全默认),got err=%v", err)
}
}
// TestConfigValidateRejectsOverCaps 钉住硬上限(design/28 §3:视口尺寸上限 + 单张字节上限)。
func TestConfigValidateRejectsOverCaps(t *testing.T) {
cases := []struct {
name string
cfg Config
}{
{"视口宽超上限", Config{ViewportWidth: MaxViewportWidth + 1}},
{"视口高超上限", Config{ViewportHeight: MaxViewportHeight + 1}},
{"截图字节超上限", Config{ScreenshotMaxBytes: MaxScreenshotMaxBytes + 1}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := tc.cfg.Validate()
if !errors.Is(err, ErrInvalidConfig) {
t.Fatalf("应返回 ErrInvalidConfig,got=%v", err)
}
})
}
}
// TestConfigValidateAcceptsAtCaps 钉住边界值(正好等于上限)合法,防止把 > 写成 >=。
func TestConfigValidateAcceptsAtCaps(t *testing.T) {
cfg := Config{
ViewportWidth: MaxViewportWidth,
ViewportHeight: MaxViewportHeight,
ScreenshotMaxBytes: MaxScreenshotMaxBytes,
}
if err := cfg.Validate(); err != nil {
t.Fatalf("边界值应合法,got err=%v", err)
}
}
// TestConfigValidateEndpoint 是 cdpEndpoint 合法性的表驱动:非 http(s) 绝对地址一律非法
// (照 config.ResolveEndpoint 口径),合法与空(=交给探测)放行。
func TestConfigValidateEndpoint(t *testing.T) {
cases := []struct {
endpoint string
wantErr bool
}{
{"", false},
{" ", false},
{"http://127.0.0.1:9222", false},
{"https://browser.internal:9222", false},
{"ftp://127.0.0.1:9222", true},
{"127.0.0.1:9222", true},
{"ws://127.0.0.1:9222", true},
{"http://", true},
}
for _, tc := range cases {
err := (Config{CDPEndpoint: tc.endpoint}).Validate()
if tc.wantErr && !errors.Is(err, ErrInvalidConfig) {
t.Fatalf("endpoint=%q 应判非法,got err=%v", tc.endpoint, err)
}
if !tc.wantErr && err != nil {
t.Fatalf("endpoint=%q 应合法,got err=%v", tc.endpoint, err)
}
}
}
// TestConfigTimeoutAccessors 钉住秒数字段到 time.Duration 的换算。
func TestConfigTimeoutAccessors(t *testing.T) {
cfg := Config{TimeoutSeconds: 12, IdleTimeoutSeconds: 90}
if got := cfg.Timeout(); got != 12*time.Second {
t.Fatalf("Timeout()=%v,want %v", got, 12*time.Second)
}
if got := cfg.IdleTimeout(); got != 90*time.Second {
t.Fatalf("IdleTimeout()=%v,want %v", got, 90*time.Second)
}
}
// TestDefaultPaths 钉住三个落点(design/13 §2.1 + design/28 §1 M2/§4)。
func TestDefaultPaths(t *testing.T) {
data := filepath.Join("root", "data")
if got, want := DefaultConfigPath(data), filepath.Join(data, "config", "plugins", "browser.json"); got != want {
t.Fatalf("DefaultConfigPath=%q,want %q", got, want)
}
if got, want := DefaultProfileDir(data), filepath.Join(data, "browser", "profile"); got != want {
t.Fatalf("DefaultProfileDir=%q,want %q", got, want)
}
if got, want := DefaultShotsDir(data), filepath.Join(data, "browser", "shots"); got != want {
t.Fatalf("DefaultShotsDir=%q,want %q", got, want)
}
}
// TestReasonMessage 钉住分类原因码都有中文文案(上层直接写日志,避免两处措辞漂移)。
func TestReasonMessage(t *testing.T) {
for _, r := range []Reason{ReasonUsable, ReasonExecutableMissing, ReasonCDPUnreachable, ReasonNoSource} {
if r.Message() == "" {
t.Fatalf("原因码 %q 缺中文文案", r)
}
}
if got := Reason("unknown").Message(); got != "" {
t.Fatalf("未知原因码应返回空串,got=%q", got)
}
}