package browsercfg import ( "errors" "os" "path/filepath" "runtime" "testing" ) // TestLoadMissingFileReturnsNil 钉住「文件不存在 = 未配置」不是错误(design/28 §8 B1: // 无配置文件时按序探测)。 func TestLoadMissingFileReturnsNil(t *testing.T) { path := filepath.Join(t.TempDir(), "browser.json") cfg, err := Load(path, t.TempDir()) if err != nil { t.Fatalf("缺文件不该报错,got err=%v", err) } if cfg != nil { t.Fatalf("缺文件应返回 nil 配置,got=%+v", cfg) } } // TestLoadRejectsBadFiles 钉住「空文件 / JSON 非法 / 字段越界」都如实报错,不静默顶替。 func TestLoadRejectsBadFiles(t *testing.T) { dir := t.TempDir() cases := []struct { name string body string }{ {"空文件", " \n"}, {"JSON 非法", "{not json"}, {"视口越上限", `{"viewportWidth": 999999}`}, {"截图字节越上限", `{"screenshotMaxBytes": 999999999}`}, {"端点非法", `{"cdpEndpoint": "ftp://127.0.0.1:9222"}`}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { path := filepath.Join(dir, tc.name+".json") if err := os.WriteFile(path, []byte(tc.body), 0o600); err != nil { t.Fatalf("准备文件失败: %v", err) } cfg, err := Load(path, dir) if !errors.Is(err, ErrInvalidConfig) { t.Fatalf("应返回 ErrInvalidConfig,got cfg=%+v err=%v", cfg, err) } if cfg != nil { t.Fatalf("非法配置不该返回配置,got=%+v", cfg) } }) } } // TestLoadNormalizesAndValidates 钉住 Load 返回的是**归一化且校验过**的配置:缺省字段被 // 默认补齐,ProfileDir 落到 dataDir 下。 func TestLoadNormalizesAndValidates(t *testing.T) { data := t.TempDir() path := DefaultConfigPath(data) if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { t.Fatalf("建目录失败: %v", err) } if err := os.WriteFile(path, []byte(`{"headless": true}`), 0o600); err != nil { t.Fatalf("写文件失败: %v", err) } cfg, err := Load(path, data) if err != nil { t.Fatalf("合法配置不该报错,got err=%v", err) } if cfg == nil { t.Fatal("合法配置不该返回 nil") } want := Config{ Headless: true, ProfileDir: DefaultProfileDir(data), ViewportWidth: DefaultViewportWidth, ViewportHeight: DefaultViewportHeight, TimeoutSeconds: DefaultTimeoutSeconds, IdleTimeoutSeconds: DefaultIdleTimeoutSeconds, Concurrency: DefaultConcurrency, ScreenshotMaxBytes: DefaultScreenshotMaxBytes, } if *cfg != want { t.Fatalf("Load 未归一化\n got=%+v\nwant=%+v", *cfg, want) } } // TestSaveRoundTrip 钉住写入后可原样读回(归一化后的形态)。 func TestSaveRoundTrip(t *testing.T) { data := t.TempDir() path := DefaultConfigPath(data) in := Config{Headless: true, ViewportWidth: 1920, CDPEndpoint: "http://127.0.0.1:9222"} if err := Save(path, data, in); err != nil { t.Fatalf("Save 失败: %v", err) } got, err := Load(path, data) if err != nil { t.Fatalf("Load 失败: %v", err) } if got == nil || *got != in.Normalize(data) { t.Fatalf("往返不一致\n got=%+v\nwant=%+v", got, in.Normalize(data)) } } // TestSaveFileMode0600 钉住配置文件是 0600(design/28 §4;Windows 不看权限位,跳过)。 func TestSaveFileMode0600(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("Windows 不体现 POSIX 权限位") } data := t.TempDir() path := DefaultConfigPath(data) if err := Save(path, data, Config{}); err != nil { t.Fatalf("Save 失败: %v", err) } info, err := os.Stat(path) if err != nil { t.Fatalf("Stat 失败: %v", err) } if perm := info.Mode().Perm(); perm != 0o600 { t.Fatalf("配置权限 = %o,want 600", perm) } } // TestSaveRejectsInvalidWithoutWriting 钉住非法配置不落盘(宁可没有配置文件)。 func TestSaveRejectsInvalidWithoutWriting(t *testing.T) { data := t.TempDir() path := DefaultConfigPath(data) err := Save(path, data, Config{CDPEndpoint: "not-a-url"}) if !errors.Is(err, ErrInvalidConfig) { t.Fatalf("应返回 ErrInvalidConfig,got=%v", err) } if _, statErr := os.Stat(path); !os.IsNotExist(statErr) { t.Fatalf("非法配置不该落盘,Stat err=%v", statErr) } } // TestSaveLeavesNoTempFile 钉住 temp+rename 收尾干净(不留 .tmp 残留)。 func TestSaveLeavesNoTempFile(t *testing.T) { data := t.TempDir() path := DefaultConfigPath(data) if err := Save(path, data, Config{}); err != nil { t.Fatalf("Save 失败: %v", err) } if _, err := os.Stat(path + ".tmp"); !os.IsNotExist(err) { t.Fatalf("不该留临时文件,Stat err=%v", err) } } // TestEnsureProfileDirCreates 钉住 profile 目录按 0700 建好(Windows 不看权限位)。 func TestEnsureProfileDirCreates(t *testing.T) { data := filepath.Join(t.TempDir(), "data") dir := DefaultProfileDir(data) if err := EnsureProfileDir(dir, data); err != nil { t.Fatalf("EnsureProfileDir 失败: %v", err) } info, err := os.Stat(dir) if err != nil || !info.IsDir() { t.Fatalf("profile 目录未建好: info=%v err=%v", info, err) } if runtime.GOOS != "windows" { if perm := info.Mode().Perm(); perm != 0o700 { t.Fatalf("profile 权限 = %o,want 700", perm) } } } // TestEnsureProfileDirRejectsEmpty 钉住空目录被拒。 func TestEnsureProfileDirRejectsEmpty(t *testing.T) { if err := EnsureProfileDir(" ", "/data"); !errors.Is(err, ErrInvalidConfig) { t.Fatalf("空目录应返回 ErrInvalidConfig,got=%v", err) } } // TestCheckProfileLocationAllowsDataDirInWorktree 钉住默认落点合法:数据目录按项目约定是 // gitignored 的运行期区,即使它落在 git 工作树内也放行。 func TestCheckProfileLocationAllowsDataDirInWorktree(t *testing.T) { root := t.TempDir() if err := os.MkdirAll(filepath.Join(root, ".git"), 0o755); err != nil { t.Fatalf("造假工作树失败: %v", err) } data := filepath.Join(root, "data") if err := CheckProfileLocation(DefaultProfileDir(data), data); err != nil { t.Fatalf("数据目录内的 profile 应合法,got err=%v", err) } } // TestCheckProfileLocationRejectsWorktreePath 钉住「会进 git」的落点被拦下(design/28 §4)。 func TestCheckProfileLocationRejectsWorktreePath(t *testing.T) { root := t.TempDir() if err := os.MkdirAll(filepath.Join(root, ".git"), 0o755); err != nil { t.Fatalf("造假工作树失败: %v", err) } dir := filepath.Join(root, "configs", "profile") err := CheckProfileLocation(dir, filepath.Join(root, "data")) if !errors.Is(err, ErrProfileInGitTree) { t.Fatalf("工作树内、且不在数据目录下的 profile 应被拒,got err=%v", err) } } // TestCheckProfileLocationAllowsOutsideWorktree 钉住工作树之外的目录合法(临时目录自身若 // 恰好在某个工作树内则跳过,保持测试与机器无关)。 func TestCheckProfileLocationAllowsOutsideWorktree(t *testing.T) { dir := t.TempDir() if worktree, ok := hasGitAncestor(dir); ok { t.Skipf("临时目录位于工作树 %s 内,跳过", worktree) } if err := CheckProfileLocation(dir, ""); err != nil { t.Fatalf("工作树之外的 profile 应合法,got err=%v", err) } }