Files
Cyrene_v2/internal/capability/browsercfg/config_test.go
T
AskaEth 29181245ce 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。
2026-10-06 09:03:34 +08:00

195 lines
6.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}