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) }