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:
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)
|
||||
}
|
||||
Reference in new issue
Block a user