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。
This commit is contained in:
AskaEth committed 2026-10-06 09:03:34 +08:00
1 parent b7fb4dd1c7
commit 29181245ce
9 files changed
+1398

No files matched your search

@@ -0,0 +1,281 @@
package browsercfg
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"os"
"testing"
)
// TestResolveNoSource 钉住「都没有 ⇒ 不假装可用」:本机无浏览器、无 CDP 端点 ⇒
// Usable=false、Reason=no_source(design/28 §8 B1、§7 验收判据 1)。
func TestResolveNoSource(t *testing.T) {
got := Resolve(context.Background(), nil, noBrowserProber())
if got.Usable {
t.Fatalf("应判不可用,got=%+v", got)
}
if got.Reason != ReasonNoSource {
t.Fatalf("Reason=%q,want %q", got.Reason, ReasonNoSource)
}
if got.Source != SourceNone {
t.Fatalf("不可用时应无来源,got=%q", got.Source)
}
}
// TestResolveLocalBrowserFromPath 钉住优先级第 1 档:PATH 上的 Chrome/Chromium 命中即用本机。
func TestResolveLocalBrowserFromPath(t *testing.T) {
p := noBrowserProber()
p.LookPath = func(name string) (string, error) {
if name == "google-chrome" {
return "/usr/bin/google-chrome", nil
}
return "", errors.New("not found")
}
got := Resolve(context.Background(), nil, p)
if !got.Usable || got.Source != SourceLocalChrome || got.Executable != "/usr/bin/google-chrome" {
t.Fatalf("应命中本机浏览器,got=%+v", got)
}
if got.Reason != ReasonUsable {
t.Fatalf("可用时 Reason 应为 usable,got=%q", got.Reason)
}
}
// TestResolveLocalBrowserFromAbsolutePath 钉住 PATH 未命中时会把固定安装位置当候选。
func TestResolveLocalBrowserFromAbsolutePath(t *testing.T) {
p := noBrowserProber()
p.Stat = func(path string) (os.FileInfo, error) {
if path == "/usr/bin/chromium" {
return executableFileInfo(), nil
}
return nil, os.ErrNotExist
}
got := Resolve(context.Background(), nil, p)
if !got.Usable || got.Executable != "/usr/bin/chromium" {
t.Fatalf("应命中固定安装位置 /usr/bin/chromium,got=%+v", got)
}
}
// TestResolveLocalBrowserSkipsDirectory 钉住目录不算可执行文件。
func TestResolveLocalBrowserSkipsDirectory(t *testing.T) {
p := noBrowserProber()
p.Stat = func(string) (os.FileInfo, error) { return testFileInfo{dir: true, mode: 0o755}, nil }
if got := Resolve(context.Background(), nil, p); got.Usable {
t.Fatalf("目录不该被当浏览器,got=%+v", got)
}
}
// TestResolveLocalBrowserNeedsExecBit 钉住非 Windows 上无执行位不算可执行(Windows 跳过)。
func TestResolveLocalBrowserNeedsExecBit(t *testing.T) {
p := noBrowserProber()
p.Stat = func(string) (os.FileInfo, error) { return testFileInfo{mode: 0o644}, nil }
if got := Resolve(context.Background(), nil, p); got.Usable {
t.Fatalf("无执行位不该算可执行,got=%+v", got)
}
p.GOOS = "windows"
p.Getenv = func(key string) string {
if key == "ProgramFiles" {
return `C:\PF`
}
return ""
}
if got := Resolve(context.Background(), nil, p); !got.Usable {
t.Fatalf("Windows 不看执行位,应判可用,got=%+v", got)
}
}
// TestResolveCDPWhenNoLocalBrowser 钉住优先级第 2 档:本机没有时看已在跑的 CDP 端点。
func TestResolveCDPWhenNoLocalBrowser(t *testing.T) {
p := noBrowserProber()
p.CheckCDP = func(_ context.Context, endpoint string) error {
if endpoint == DefaultCDPEndpoint {
return nil
}
return errors.New("connection refused")
}
got := Resolve(context.Background(), nil, p)
if !got.Usable || got.Source != SourceCDP || got.Endpoint != DefaultCDPEndpoint {
t.Fatalf("应命中 CDP 端点,got=%+v", got)
}
}
// TestResolveLocalPreferredOverCDP 钉住优先级顺序(design/28 §8 B1:本机 → 容器)。
func TestResolveLocalPreferredOverCDP(t *testing.T) {
p := noBrowserProber()
p.LookPath = func(string) (string, error) { return "/usr/bin/chromium", nil }
p.CheckCDP = func(context.Context, string) error { return nil }
got := Resolve(context.Background(), nil, p)
if got.Source != SourceLocalChrome {
t.Fatalf("本机应优先于 CDP,got=%+v", got)
}
}
// TestResolveConfiguredExecutable 钉住面板显式指定的可执行文件:存在即可用、缺失即分类报错。
func TestResolveConfiguredExecutable(t *testing.T) {
ok := noBrowserProber()
ok.Stat = func(path string) (os.FileInfo, error) {
if path == "/opt/chrome" {
return executableFileInfo(), nil
}
return nil, os.ErrNotExist
}
got := Resolve(context.Background(), &Config{Executable: "/opt/chrome"}, ok)
if !got.Usable || got.Source != SourceLocalChrome || got.Executable != "/opt/chrome" {
t.Fatalf("配置的可执行文件应被采纳,got=%+v", got)
}
got = Resolve(context.Background(), &Config{Executable: "/opt/missing"}, noBrowserProber())
if got.Usable || got.Reason != ReasonExecutableMissing {
t.Fatalf("缺失应报 %q,got=%+v", ReasonExecutableMissing, got)
}
}
// TestResolveConfiguredEndpoint 钉住面板显式指定的 CDP 端点:可达即可用、不可达分类报错。
func TestResolveConfiguredEndpoint(t *testing.T) {
p := noBrowserProber()
p.CheckCDP = func(_ context.Context, endpoint string) error {
if endpoint == "http://127.0.0.1:9333" {
return nil
}
return errors.New("connection refused")
}
got := Resolve(context.Background(), &Config{CDPEndpoint: "http://127.0.0.1:9333"}, p)
if !got.Usable || got.Source != SourceCDP || got.Endpoint != "http://127.0.0.1:9333" {
t.Fatalf("配置的 CDP 端点应被采纳,got=%+v", got)
}
got = Resolve(context.Background(), &Config{CDPEndpoint: "http://127.0.0.1:9"}, p)
if got.Usable || got.Reason != ReasonCDPUnreachable {
t.Fatalf("不可达应报 %q,got=%+v", ReasonCDPUnreachable, got)
}
}
// TestResolveConfiguredEndpointDoesNotFallBack 是「不许静默回退」的守门测试:面板配了端点、
// 端点连不上时,**即使本机明明有浏览器**也必须如实报 cdp_unreachable,不许改走本机。
func TestResolveConfiguredEndpointDoesNotFallBack(t *testing.T) {
p := noBrowserProber()
p.LookPath = func(string) (string, error) { return "/usr/bin/chromium", nil }
got := Resolve(context.Background(), &Config{CDPEndpoint: "http://127.0.0.1:9"}, p)
if got.Usable || got.Source != SourceNone || got.Reason != ReasonCDPUnreachable {
t.Fatalf("配了端点并连不上时必须如实报错、不得回退本机,got=%+v", got)
}
}
// TestResolveConfiguredExecutableWinsOverEndpoint 钉住显式来源的优先序:executable 先于
// cdpEndpoint(同一个「本机 → 容器」的口径),命中 executable 时不该再去探测端点。
func TestResolveConfiguredExecutableWinsOverEndpoint(t *testing.T) {
p := noBrowserProber()
p.Stat = func(path string) (os.FileInfo, error) {
if path == "/opt/chrome" {
return executableFileInfo(), nil
}
return nil, os.ErrNotExist
}
probedEndpoint := false
p.CheckCDP = func(context.Context, string) error { probedEndpoint = true; return nil }
got := Resolve(context.Background(), &Config{Executable: "/opt/chrome", CDPEndpoint: "http://127.0.0.1:9333"}, p)
if got.Source != SourceLocalChrome {
t.Fatalf("executable 应优先,got=%+v", got)
}
if probedEndpoint {
t.Fatal("命中 executable 时不该再探测 CDP 端点")
}
}
// TestResolveHeadlessFromConfig 钉住档位来自配置(默认有头,显式 true 才无头)。
func TestResolveHeadlessFromConfig(t *testing.T) {
p := noBrowserProber()
p.LookPath = func(string) (string, error) { return "/usr/bin/chromium", nil }
if got := Resolve(context.Background(), &Config{}, p); got.Headless {
t.Fatalf("默认档应为有头,got=%+v", got)
}
if got := Resolve(context.Background(), &Config{Headless: true}, p); !got.Headless {
t.Fatalf("显式无头应被采纳,got=%+v", got)
}
if got := Resolve(context.Background(), nil, p); got.Headless {
t.Fatalf("无配置时应为有头默认,got=%+v", got)
}
}
// TestProberWithDefaults 钉住零值 Prober 也被补上真实现(零值 = 全默认)。
func TestProberWithDefaults(t *testing.T) {
p := (&Prober{}).withDefaults()
if p.LookPath == nil || p.Stat == nil || p.Getenv == nil || p.CheckCDP == nil || p.GOOS == "" {
t.Fatalf("withDefaults 未补齐字段:%+v", p)
}
}
// TestNewProber 钉住 NewProber 返回的也是补齐过的实现(生产路径取它)。
func TestNewProber(t *testing.T) {
p := NewProber()
if p.LookPath == nil || p.Stat == nil || p.Getenv == nil || p.CheckCDP == nil || p.GOOS == "" {
t.Fatalf("NewProber 未补齐字段:%+v", p)
}
}
// TestCheckCDP 用本机 httptest 服务钉住默认探测实现(生产路径,不能不测):
// `/json/version` 返回 2xx 即可达;非 2xx、连不上都算不可达;尾部斜杠容错。
func TestCheckCDP(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/json/version" {
http.NotFound(w, r)
return
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"Browser":"Chrome/120"}`))
}))
defer srv.Close()
if err := checkCDP(context.Background(), srv.URL); err != nil {
t.Fatalf("可达端点应返回 nil,got=%v", err)
}
if err := checkCDP(context.Background(), srv.URL+"/"); err != nil {
t.Fatalf("尾部斜杠应被容错,got=%v", err)
}
bad := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusForbidden)
}))
defer bad.Close()
if err := checkCDP(context.Background(), bad.URL); err == nil {
t.Fatal("非 2xx 应判不可达")
}
if err := checkCDP(context.Background(), "http://127.0.0.1:1"); err == nil {
t.Fatal("连不上应报错")
}
}
// TestCandidateTables 钉住三平台的候选表都非空(避免某个平台分支写成空表 ⇒ 该平台永远
// 探测不到浏览器),以及 Windows 候选完全由安装目录环境变量驱动。
func TestCandidateTables(t *testing.T) {
for _, goos := range []string{"windows", "darwin", "linux"} {
if len(lookPathNames(goos)) == 0 {
t.Fatalf("%s 的 PATH 候选为空", goos)
}
}
env := map[string]string{"ProgramFiles": `C:\PF`, "LOCALAPPDATA": `C:\LA`}
getenv := func(k string) string { return env[k] }
for _, goos := range []string{"windows", "darwin", "linux"} {
if got := absoluteCandidates(goos, getenv); len(got) == 0 {
t.Fatalf("%s 的绝对路径候选为空", goos)
}
}
if got := absoluteCandidates("windows", func(string) string { return "" }); len(got) != 0 {
t.Fatalf("无安装目录环境变量时 windows 候选应为空,got=%v", got)
}
}
// TestResolveNilProberDoesNotPanic 钉住 p=nil 不 panic(不连真实端点:用一个必失败的
// 上下文超时让它立刻返回)。
func TestResolveNilProberDoesNotPanic(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
defer func() {
if r := recover(); r != nil {
t.Fatalf("Resolve(nil prober) panic: %v", r)
}
}()
_ = Resolve(ctx, nil, nil)
}