按 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。
210 lines
7.0 KiB
Go
210 lines
7.0 KiB
Go
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)
|
||
}
|
||
}
|