按 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。
282 lines
11 KiB
Go
282 lines
11 KiB
Go
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)
|
||
}
|