Files
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

210 lines
7.0 KiB
Go
Raw Permalink 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"
"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)
}
}