按 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。
195 lines
6.9 KiB
Go
195 lines
6.9 KiB
Go
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)
|
||
}
|
||
}
|